[PROPOSAL] Flavoured and branded types
See original GitHub issueYes, I know about https://github.com/colinhacks/zod/issues/3 but I couldn’t find anything saying that you didn’t want to support nominal types in Zod 😄
Type alias
A type alias is just a name given to an already existing type. These won’t prevent assigning values between types.
// Those three types are all number underneath
type Age = number;
type DistanceInMeters = number;
type DistanceInMiles = number;
const age = 10;
const distanceInMeters = age;
const distanceInMiles = distanceInMeters;
// Those two types are all string underneath
type Name = string;
type DateISO8601 = string;
const name = "Zod";
const date = name;
// Easy to do with Zod
type TownName = z.string();
Tag
A tag is a property in a type that is used to differentiate it from other types. They are used to create discriminated unions.
type Shape = {
area: number;
} & ({ radius: number } | { length: number });
// distinguishing between a Circle and a Square is annoying
declare const shape: Shape;
if ("radius" in shape) {
console.log("The radius is " + shape.radius);
} else {
console.log("The length is " + shape.length);
}
// and you can't use a switch or a Record to handle all the types of shapes
// which is why tags are useful
type Animal =
| {
tag: "dog";
barkStrength: number;
}
| {
tag: "cat";
purrStrength: number;
};
declare const animal: Animal;
switch (animal.tag) {
case "dog":
// you can use barkStrength here
case "cat":
// you can use purrStrength here
}
// Easy to do with Zod
const shapeSchema = z.union([
z.object({
tag: z.literal("circle"),
radius: z.number(),
}),
z.object({
tag: z.literal("square"),
length: z.number(),
}),
]);
Flavoured types
Sometimes you don’t want types to be interchangeable, such as distanceInMeter
and distanceInMiles
, so you could use flavoured type to prevent this. A flavoured type is a type with tag to prevent mixing flavoured types between themselves
type DistanceInMeter = number & { readonly brand?: unique symbol };
type DistanceInMiles = number & { readonly brand?: unique symbol };
const distanceInMeter: DistanceInMeter = 10; // this works
const distanceInMiles: DistanceInMiles = distanceInMeter; // this won't compile
You can’t do this with Zod, you have to create a type and have your Zod schema use this type
type Flavor<T, FlavorT> = T & { _type?: FlavorT; };
type UUID = Flavor<string, 'UUID'>;
const uuid: z.Schema<UUID> = z.string().uuid() as any;
It would be easier just to be able to use Zod to do this
const uuidSchema = z.string().uuid().flavour<"UUID">();
type UUID = z.infer<typeof uuidSchema>;
const uuid: UUID = "123e4567-e89b-12d3-a456-426614174000";
The code shouldn’t be too complex too
export interface ZodFlavourDef<T extends ZodTypeAny> extends ZodTypeDef {
innerType: T;
typeName: "ZodFlavour";
}
export type ZodFlavourType<
B extends string,
T extends ZodTypeAny = ZodTypeAny
> = ZodNominal<B, T>;
type ZodFlavourOutput<
B extends string,
T extends ZodTypeAny = ZodTypeAny
> = T["_output"] & { readonly __flavour?: B };
export class ZodFlavour<
B extends string,
T extends ZodTypeAny = ZodTypeAny
> extends ZodType<ZodFlavourOutput<B, T>, ZodFlavourDef<T>, T["_input"]> {
_parse(
ctx: ParseContext,
data: any,
parsedType: ZodParsedType
): ParseReturnType<T["_output"]> {
return this._def.innerType._parse(ctx, data, parsedType);
}
unwrap() {
return this._def.innerType;
}
static create = <B extends string, T extends ZodTypeAny = ZodTypeAny>(
type: T,
params?: RawCreateParams
): ZodFlavour<B, T> => {
return new ZodFlavour({
innerType: type,
typeName: "ZodFlavour",
...processCreateParams(params),
}) as any;
};
}
Branded types
These are the same as flavoured types but they don’t accept primitive and have to be build using parse
for example
const uuidSchema = z.string().uuid().brand<"UUID">();
type UUID = z.infer<typeof uuidSchema>;
const uuid: UUID = "123e4567-e89b-12d3-a456-426614174000"; // won't compile
const uuid2: UUID = uuidSchema.parse("123e4567-e89b-12d3-a456-426614174000");
It would be the same code as above except for the ?
type ZodBrandOutput<
B extends string,
T extends ZodTypeAny = ZodTypeAny
> = T["_output"] & { readonly __brand: B };
Issue Analytics
- State:
- Created 2 years ago
- Reactions:10
- Comments:8
Top GitHub Comments
I think it’s pretty trivial to use branded types with zod using refinement:
Since there are different methods of creating branded types, e.g. intersecting with enums, unique symbols, etc, I think it’s preferable to leave that up to the user rather than zod choosing one for you.
For what it is worth, I’ve created a small proof of concept library (dubbed vod) for usage in my private projects that provides validated value objects based on zod. It’s not something I would suggest to be used in production (yet?) but it might serve as source of inspiration. In addition to using the branded type approach it also employs a few other tricks to avoid being able to create value objects that have not been validated (such as using spreading to create a new object).