colinhacks / zod

TypeScript-first schema validation with static type inference
https://zod.dev
MIT License
34.04k stars 1.19k forks source link

How to make ZodUnion from a enum? #2691

Open OnkelTem opened 1 year ago

OnkelTem commented 1 year ago

Imagine we have an API where some types are list of options. It can be done via enums, arrays or objects. Here's a enum example:

enum color {
  red = "red",
  green = "green",
};

Imagine also, that we have to validate user input. Using Zod we can create a schema:

const colorSchema = z.union([z.literal("red"), z.literal("green")])

and then use it for validation:

const color = colorSchema.parse(userInput)
      //^? color: "red" | "green"

The problem with this approach is that we're breaking the DRY principle: we have to repeat every value from every enum from the API, which brings even bigger problem with keeping it up to date. If a new color gets added to the enum, how will our schema know about it? There is no way. So the only solid way to do it, is to convert enums to z.unions.

However, I couldn't figure out how to do this. z.union() accepts an array of literals, but I cannot find a solution how to create ZodUnion from a enum.

Here are a couple of sandboxes, that differ only in the way how options are defined:

Check out the ***-marked lines.

OnkelTem commented 1 year ago

Thanks to folks from the TypeScript Discord channel, I've finally come up with this solution.


import { z } from "zod";

// API: we have some API which returns themes by color ids

enum color {
  red = "red",
  green = "green",
};
type ColorId = keyof typeof color

type ThemeRequest = {
  colorId: ColorId;
};

declare function getThemeApi(req: ThemeRequest): any;

// UTILTIY TYPES, to convert TS union to TS tuple

type ToIntersection<T> = (T extends T ? (_: T) => 0 : never) extends (_: infer U) => 0
    ? U
    : never;

type Last<T> = ToIntersection<T extends unknown ? () => T : never> extends () => infer U
    ? U
    : never;

type Keys<T> = [T] extends [never] ? [] : [...Keys<Exclude<T, Last<T>>>, Last<T>];

// UTILITY TYPE, that takes object type and retuns a union of ZodLiteral's

type MakeValues<T> = { [K in keyof T]: z.ZodLiteral<T[K]> }[keyof T]

type _V = MakeValues<typeof color>
    //^?

// UTILITY FUNCTION, that takes object and returns an array z.literal() but asserts its
// type to be a tuple of specific values from the object keys.

function makeValues<T extends Record<string, unknown>>(options: T) {
  return Object.keys(options).map((k) => z.literal(k)) as Keys<MakeValues<T>>
}

const _F = makeValues(color)
     //^?

// ZOD SCHEMA, which combines things from above

const colorRequestSchema = z.object({
  colorId: z.union(makeValues(color))
});

// USAGE: let's write some code

function getTheme(colorId: string) {
  const request = colorRequestSchema.parse({ colorId });
        //^?
  getThemeApi(request);
}
OnkelTem commented 1 year ago

Update. It finally failed in the end due to the limitation of TS. See: https://github.com/microsoft/TypeScript/issues/34933#issuecomment-1696519995

I believe ZodUnion shouldn't be used for tasks like this one.

Nishchit14 commented 4 months ago

It should work: https://stackoverflow.com/a/76797654

andrienko commented 2 months ago

In case you have similar problem I had - the trick was to use `${EnumName}` which resolves to union in TS, and to convince TS Object.values of enum is that union:

const myEnumSchema = z.enum(Object.values(MyEnum) as [`${MyEnum}`]);

This returns a schema, that infers to union of enum values.


for example:

type FruitsUnion = 'Apples' | 'Bananas' | 'Oranges';

enum FruitsEnum {
  ApplesWithDifferentKey = 'Apples',
  Bananas = 'Bananas',
  WhateverSomethingElse = 'Oranges',
}

const fruitsEnumSchema = z.enum(Object.values(FruitsEnum) as [`${FruitsEnum}`]);
// z.ZodEnum<["Apples" | "Bananas" | "Oranges"]>

type FruitsEnumUnion = z.infer<typeof fruitsEnumSchema>;
// "Apples" | "Bananas" | "Oranges"

assert<Equals<FruitsUnion, FruitsEnumUnion>>();
// No error

assert<Equals<'Apples' | 'Bananas', FruitsEnumUnion>>();
assert<Equals<'Apples' | 'Bananas' | 'Oranges' | 'Tomatoes', FruitsEnumUnion>>();
// error

console.log('Parsing result:', fruitsEnumSchema.safeParse('Apples'));
// {success: true, data: 'Apples'}
console.log('Parsing result:', fruitsEnumSchema.safeParse('Tomatoes'));
// {success: false}