Skip to content

Commit

Permalink
Add branded types (#1279)
Browse files Browse the repository at this point in the history
* Add branded types

* Remove comments

* Support branding inheritance, number and symbol brands

* Fix tests

* Add unwrap getter

Co-authored-by: Colin McDonnell <colinmcd@alum.mit.edu>
  • Loading branch information
colinhacks and Colin McDonnell committed Aug 9, 2022
1 parent 88e94ef commit 7c113ab
Show file tree
Hide file tree
Showing 10 changed files with 325 additions and 20 deletions.
44 changes: 43 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
- [Instanceof](#instanceof)
- [Function schemas](#function-schemas)
- [Preprocess](#preprocess)
- [Branded types](#branded-types)
- [Schema methods](#schema-methods)
- [.parse](#parse)
- [.parseAsync](#parseasync)
Expand Down Expand Up @@ -1465,7 +1466,7 @@ All Zod schemas contain certain methods.

### `.parse`

`.parse(data:unknown): T`
`.parse(data: unknown): T`

Given any Zod schema, you can call its `.parse` method to check `data` is valid. If it is, a value is returned with full type information! Otherwise, an error is thrown.

Expand Down Expand Up @@ -1877,6 +1878,47 @@ z.object({ name: z.string() }).and(z.object({ age: z.number() })); // { name: st
z.intersection(z.object({ name: z.string() }), z.object({ age: z.number() }));
```

### `.brand`

`.brand<T>() => ZodBranded<this, B>`

TypeScript's type system is structural, which means that any two types that are structurally equivalent are considered the same.

```ts
type Cat = { name: string };
type Dog = { name: string };

const petCat = (cat: Cat) => {};
const fido: Dog = { name: "fido" };
petCat(fido); // works fine
```

In some cases, its can be desirable to simulate _nominal typing_ inside TypeScript. For instance, you may wish to write a function that only accepts an input that has been validated by Zod. This can be achieved with _branded types_ (AKA _opaque types_).

```ts
const Cat = z.object({ name: z.string }).brand<"Cat">();
type Cat = z.infer<typeof Cat>;

const petCat = (cat: Cat) => {};

// this works
const simba = Cat.parse({ name: "simba" });
petCat(simba);

// this doesn't
petCat({ name: "fido" });
```

Under the hood, this works by attaching a "brand" to the inferred type using an intersection type. This way, plain/unbranded data structures are no longer assignable to the inferred type of the schema.

```ts
const Cat = z.object({ name: z.string }).brand<"Cat">();
type Cat = z.infer<typeof Cat>;
// {name: string} & {[symbol]: "Cat"}
```

Note that branded types do not affect the runtime result of `.parse`. It is a static-only construct.

## Guides and concepts

### Type inference
Expand Down
44 changes: 43 additions & 1 deletion deno/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
- [Instanceof](#instanceof)
- [Function schemas](#function-schemas)
- [Preprocess](#preprocess)
- [Branded types](#branded-types)
- [Schema methods](#schema-methods)
- [.parse](#parse)
- [.parseAsync](#parseasync)
Expand Down Expand Up @@ -1465,7 +1466,7 @@ All Zod schemas contain certain methods.

### `.parse`

`.parse(data:unknown): T`
`.parse(data: unknown): T`

Given any Zod schema, you can call its `.parse` method to check `data` is valid. If it is, a value is returned with full type information! Otherwise, an error is thrown.

Expand Down Expand Up @@ -1877,6 +1878,47 @@ z.object({ name: z.string() }).and(z.object({ age: z.number() })); // { name: st
z.intersection(z.object({ name: z.string() }), z.object({ age: z.number() }));
```

### `.brand`

`.brand<T>() => ZodBranded<this, B>`

TypeScript's type system is structural, which means that any two types that are structurally equivalent are considered the same.

```ts
type Cat = { name: string };
type Dog = { name: string };

const petCat = (cat: Cat) => {};
const fido: Dog = { name: "fido" };
petCat(fido); // works fine
```

In some cases, its can be desirable to simulate _nominal typing_ inside TypeScript. For instance, you may wish to write a function that only accepts an input that has been validated by Zod. This can be achieved with _branded types_ (AKA _opaque types_).

```ts
const Cat = z.object({ name: z.string }).brand<"Cat">();
type Cat = z.infer<typeof Cat>;

const petCat = (cat: Cat) => {};

// this works
const simba = Cat.parse({ name: "simba" });
petCat(simba);

// this doesn't
petCat({ name: "fido" });
```

Under the hood, this works by attaching a "brand" to the inferred type using an intersection type. This way, plain/unbranded data structures are no longer assignable to the inferred type of the schema.

```ts
const Cat = z.object({ name: z.string }).brand<"Cat">();
type Cat = z.infer<typeof Cat>;
// {name: string} & {[symbol]: "Cat"}
```

Note that branded types do not affect the runtime result of `.parse`. It is a static-only construct.

## Guides and concepts

### Type inference
Expand Down
58 changes: 58 additions & 0 deletions deno/lib/__tests__/branded.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// @ts-ignore TS6133
import { expect } from "https://deno.land/x/expect@v0.2.6/mod.ts";
const test = Deno.test;

import { util } from "../helpers/util.ts";
import * as z from "../index.ts";

test("branded types", () => {
const mySchema = z
.object({
name: z.string(),
})
.brand<"superschema">();

// simple branding
type MySchema = z.infer<typeof mySchema>;
const f1: util.AssertEqual<
MySchema,
{ name: string } & { [z.BRAND]: { superschema: true } }
> = true;
f1;
const doStuff = (arg: MySchema) => arg;
doStuff(mySchema.parse({ name: "hello there" }));

// inheritance
const extendedSchema = mySchema.brand<"subschema">();
type ExtendedSchema = z.infer<typeof extendedSchema>;
const f2: util.AssertEqual<
ExtendedSchema,
{ name: string } & { [z.BRAND]: { superschema: true; subschema: true } }
> = true;
f2;
doStuff(extendedSchema.parse({ name: "hello again" }));

// number branding
const numberSchema = z.number().brand<42>();
type NumberSchema = z.infer<typeof numberSchema>;
const f3: util.AssertEqual<
NumberSchema,
number & { [z.BRAND]: { 42: true } }
> = true;
f3;

// symbol branding
const MyBrand: unique symbol = Symbol("hello");
type MyBrand = typeof MyBrand;
const symbolBrand = z.number().brand<"sup">().brand<typeof MyBrand>();
type SymbolBrand = z.infer<typeof symbolBrand>;
// number & { [z.BRAND]: { sup: true, [MyBrand]: true } }
const f4: util.AssertEqual<
SymbolBrand,
number & { [z.BRAND]: { sup: true; [MyBrand]: true } }
> = true;
f4;

// @ts-expect-error
doStuff({ name: "hello there!" });
});
2 changes: 2 additions & 0 deletions deno/lib/__tests__/firstparty.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ test("first party switch", () => {
break;
case z.ZodFirstPartyTypeKind.ZodPromise:
break;
case z.ZodFirstPartyTypeKind.ZodBranded:
break;
default:
util.assertNever(def);
}
Expand Down
2 changes: 1 addition & 1 deletion deno/lib/helpers/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export namespace util {
};

export type identity<T> = T;
export type flatten<T extends object> = identity<{ [k in keyof T]: T[k] }>;
export type flatten<T> = identity<{ [k in keyof T]: T[k] }>;
export type noUndefined<T> = T extends undefined ? never : T;

export const isInteger: NumberConstructor["isInteger"] =
Expand Down
67 changes: 59 additions & 8 deletions deno/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,14 @@ export abstract class ZodType<
}) as any;
}

brand<B extends string | number | symbol>(): ZodBranded<this, B> {
return new ZodBranded({
typeName: ZodFirstPartyTypeKind.ZodBranded,
type: this,
...processCreateParams(undefined),
});
}

describe(description: string): this {
const This = (this as any).constructor;
return new This({
Expand Down Expand Up @@ -3682,13 +3690,13 @@ export class ZodDefault<T extends ZodTypeAny> extends ZodType<
};
}

/////////////////////////////////////////
/////////////////////////////////////////
////////// //////////
////////// ZodNaN //////////
////////// //////////
/////////////////////////////////////////
/////////////////////////////////////////
//////////////////////////////////////
//////////////////////////////////////
////////// //////////
////////// ZodNaN //////////
////////// //////////
//////////////////////////////////////
//////////////////////////////////////

export interface ZodNaNDef extends ZodTypeDef {
typeName: ZodFirstPartyTypeKind.ZodNaN;
Expand Down Expand Up @@ -3718,6 +3726,47 @@ export class ZodNaN extends ZodType<number, ZodNaNDef> {
};
}

//////////////////////////////////////////
//////////////////////////////////////////
////////// //////////
////////// ZodBranded //////////
////////// //////////
//////////////////////////////////////////
//////////////////////////////////////////

export interface ZodBrandedDef<T extends ZodTypeAny> extends ZodTypeDef {
type: T;
typeName: ZodFirstPartyTypeKind.ZodBranded;
}

export const BRAND: unique symbol = Symbol("zod_brand");
type Brand<T extends string | number | symbol> = {
[BRAND]: { [k in T]: true };
};

export class ZodBranded<
T extends ZodTypeAny,
B extends string | number | symbol
> extends ZodType<
T["_output"] & Brand<B>,
ZodBrandedDef<T>,
T["_input"] & Brand<B>
> {
_parse(input: ParseInput): ParseReturnType<any> {
const { ctx } = this._processInputParams(input);
const data = ctx.data;
return this._def.type._parse({
data,
path: ctx.path,
parent: ctx,
});
}

unwrap() {
return this._def.type;
}
}

export const custom = <T>(
check?: (data: unknown) => any,
params: Parameters<ZodTypeAny["refine"]>[1] = {},
Expand Down Expand Up @@ -3772,6 +3821,7 @@ export enum ZodFirstPartyTypeKind {
ZodNullable = "ZodNullable",
ZodDefault = "ZodDefault",
ZodPromise = "ZodPromise",
ZodBranded = "ZodBranded",
}
export type ZodFirstPartySchemaTypes =
| ZodString
Expand Down Expand Up @@ -3804,7 +3854,8 @@ export type ZodFirstPartySchemaTypes =
| ZodOptional<any>
| ZodNullable<any>
| ZodDefault<any>
| ZodPromise<any>;
| ZodPromise<any>
| ZodBranded<any, any>;

const instanceOfType = <T extends new (...args: any[]) => any>(
cls: T,
Expand Down
57 changes: 57 additions & 0 deletions src/__tests__/branded.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// @ts-ignore TS6133
import { expect, test } from "@jest/globals";

import { util } from "../helpers/util";
import * as z from "../index";

test("branded types", () => {
const mySchema = z
.object({
name: z.string(),
})
.brand<"superschema">();

// simple branding
type MySchema = z.infer<typeof mySchema>;
const f1: util.AssertEqual<
MySchema,
{ name: string } & { [z.BRAND]: { superschema: true } }
> = true;
f1;
const doStuff = (arg: MySchema) => arg;
doStuff(mySchema.parse({ name: "hello there" }));

// inheritance
const extendedSchema = mySchema.brand<"subschema">();
type ExtendedSchema = z.infer<typeof extendedSchema>;
const f2: util.AssertEqual<
ExtendedSchema,
{ name: string } & { [z.BRAND]: { superschema: true; subschema: true } }
> = true;
f2;
doStuff(extendedSchema.parse({ name: "hello again" }));

// number branding
const numberSchema = z.number().brand<42>();
type NumberSchema = z.infer<typeof numberSchema>;
const f3: util.AssertEqual<
NumberSchema,
number & { [z.BRAND]: { 42: true } }
> = true;
f3;

// symbol branding
const MyBrand: unique symbol = Symbol("hello");
type MyBrand = typeof MyBrand;
const symbolBrand = z.number().brand<"sup">().brand<typeof MyBrand>();
type SymbolBrand = z.infer<typeof symbolBrand>;
// number & { [z.BRAND]: { sup: true, [MyBrand]: true } }
const f4: util.AssertEqual<
SymbolBrand,
number & { [z.BRAND]: { sup: true; [MyBrand]: true } }
> = true;
f4;

// @ts-expect-error
doStuff({ name: "hello there!" });
});
2 changes: 2 additions & 0 deletions src/__tests__/firstparty.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ test("first party switch", () => {
break;
case z.ZodFirstPartyTypeKind.ZodPromise:
break;
case z.ZodFirstPartyTypeKind.ZodBranded:
break;
default:
util.assertNever(def);
}
Expand Down
2 changes: 1 addition & 1 deletion src/helpers/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export namespace util {
};

export type identity<T> = T;
export type flatten<T extends object> = identity<{ [k in keyof T]: T[k] }>;
export type flatten<T> = identity<{ [k in keyof T]: T[k] }>;
export type noUndefined<T> = T extends undefined ? never : T;

export const isInteger: NumberConstructor["isInteger"] =
Expand Down
Loading

0 comments on commit 7c113ab

Please sign in to comment.