zod icon indicating copy to clipboard operation
zod copied to clipboard

How to use Zod schema with existing TypeScript interface?

Open JacobWeisenburger opened this issue 1 year ago • 25 comments

Discussed in https://github.com/colinhacks/zod/discussions/2796

Originally posted by adamerose September 24, 2023 I see Zod described as a "TypeScript-first" library but am struggling to figure out how to actually integrate it with my TypeScript definitions. Is there a way to pass a TypeScript interface to my Zod schema as a generic and have my linter tell me if they're not compatible?

This is how it looks in Yup:

import { object, number, string, ObjectSchema } from 'yup';

interface Person {
  name: string;
  age?: number;
  sex: 'male' | 'female' | 'other' | null;
}

// will raise a compile-time type error if the schema does not produce a valid Person
const schema: ObjectSchema<Person> = object({
  name: string().defined(),
  age: number().optional(),
  sex: string<'male' | 'female' | 'other'>().nullable().defined(),
});

// ❌ errors:
// "Type 'number | undefined' is not assignable to type 'string'."
const badSchema: ObjectSchema<Person> = object({
  name: number(),
});

I found discussion here and here but didn't see any solution I could understand, or relied on codegen and external libraries.

I think something like this might work? But it's very boilerplatey and I don't see anything like this in the docs:

interface Person {
  name: string;
  age?: number;
  sex: "male" | "female" | "other" | null;
}

const zodSchema = z.object({
  name: z.string(),
  age: z.number().optional(),
  sex: z.enum(["male", "female", "other"]).nullable(),
});

type InferredPerson = z.infer<typeof zodSchema>;
function assertType<T>(_value: T) {}
assertType<Person>({} as InferredPerson);
```</div>

JacobWeisenburger avatar Sep 25 '23 17:09 JacobWeisenburger

In practice (assuming that you actually do something with your parsed data), wouldn't code like this be enough to do such a check?

import z from 'zod';

interface Person {
  name: string;
  age?: number;
  sex: "male" | "female" | "other" | null;
}

function doSomethingWithPerson(person: Person) {
 return person;
}

const badSchema = z.object({
  name: z.number(),
});

const personBad = badSchema.parse({ name: 'foo' });

doSomethingWithPerson(personBad);
// ❌ Argument of type '{ name: number; }' is not assignable to parameter of type 'Person'.
//    Property 'sex' is missing in type '{ name: number; }' but required in type 'Person'.(2345)

This will throw an error if your schema does not match the interface.

I'm not sure why you would need an additional assertion, but if you do, code like you suggested would be fine for that.

graup avatar Sep 26 '23 14:09 graup

I'm also wondering how this can be achieved. I have an existing interface I'd like to make sure that my zod schema always matches. It's easy enough to run my existing interface thru a zod schema generator and generate the matching zod schema but that does not ensure it stays in sync over time. If the original interface is updated, I want a compile time error telling me to update my zod schema. This is is possible with the io-ts library but I am struggling to figure out how to do it with zod.

mikeyaa avatar Oct 08 '23 01:10 mikeyaa

Adding my two cents to this. I also don't see any solution actually being given in issues such as #53.

I don't want Zod to determine what my TypeScript types and interfaces look like, I want my TypeScript types and interfaces to determine what Zod looks like.

I would like:

interface Car {
make: string;
model: string;
}

To lead to:

z.object({ make: z.string(), model: z.string() });

Not the other way around.

As awesome as Zod is, and how eager I was to use it, I put more trust into TypeScript being around and maintained for a long time than I do in Zod. If for any reason I need to move away from Zod in the future, if all my types are inferred through Zod, that becomes a real issue.

As eager as I was to use Zod on my current project, I will probably choose something else instead because of this design choice.

adriangabardo avatar Oct 16 '23 06:10 adriangabardo

I think because TS is a superset of JS and an interface doesn't actually exist in JS-land, there's nothing tangible from an interface come transpile/run time. Ultimately there would need to be something that generated code at build, or even a code generator before build/run time that would take TS interfaces and build out the associated code validation (ie: Zod). Maybe there's some type of reflection that could be utilized to help do this?

Some interesting links around such:

  • https://radzserg.medium.com/reflection-in-typescript-af68a1536ea1
    • https://www.typescriptlang.org/docs/handbook/decorators.html#metadata
  • https://stackoverflow.com/questions/41564078/typescript-reflection-for-interfaces
    • https://typescript-rtti.org/

ctsstc avatar Dec 12 '23 00:12 ctsstc

In practice (assuming that you actually do something with your parsed data), wouldn't code like this be enough to do such a check?

import z from 'zod';

interface Person {
  name: string;
  age?: number;
  sex: "male" | "female" | "other" | null;
}

function doSomethingWithPerson(person: Person) {
 return person;
}

const badSchema = z.object({
  name: z.number(),
});

const personBad = badSchema.parse({ name: 'foo' });

doSomethingWithPerson(personBad);
// ❌ Argument of type '{ name: number; }' is not assignable to parameter of type 'Person'.
//    Property 'sex' is missing in type '{ name: number; }' but required in type 'Person'.(2345)

This will throw an error if your schema does not match the interface.

I'm not sure why you would need an additional assertion, but if you do, code like you suggested would be fine for that.

I use this solution and it has met my needs. The zod object is not based on my interface but I have the linter notifying me if it is in agreement

DavidD344 avatar Jan 02 '24 22:01 DavidD344

Hello, everyone.

Actually we can achieve the inverse. Instead of using TypeScript interface for Zod's schema, we can use the Zod's "infer" functionality to get TypeScript type definition from Zod's schema. Here is the example:

const PersonSchema = z.object({
	name: z.string(),
});

export type Person = z.infer<typeof PersonSchema>;

The TypeScript type checking also works this way:

Screenshot 2024-01-06 222342

ariadng avatar Jan 06 '24 15:01 ariadng

I think the prefered way of using it / the perfect solution would be something like:

//simple interface or type with any properties, most likely generated
interface Human {
  name: string;
  age?: number;
}

// to any zod-schema:
const schema = z.from<Human>();
// or even something like this would be OK:
class Person implements Human {
// ... constructor etc
}
const another = z.create(Person);

// you would parse as normal
const unknownData = {propA: "a", name: "john", age: "123"}

const parsed = schema.parse(unknownData);
// ^? = typeof Human

// same for .safeParse and safeParseAsync

Would that even be possible to extract properties and types from an interface?

m10rten avatar Feb 12 '24 12:02 m10rten

Would that even be possible to extract properties and types from an interface?

This is kind of backwards and not at all the goal of zod. It is not possible to create runtime-code from TS without additional build steps. Zod is a JS library, not a build tool. But someone else could create such a "ts-to-zod" bundler plugin, which could use typescript AST parsing to generate new JS code at build time. However, then your TS types become the source of truth, and those are less expressive than what zod provides.

/edit: just saw that someone built this: https://github.com/fabien0102/ts-to-zod

graup avatar Feb 12 '24 12:02 graup

However, then your TS types become the source of truth, and those are less expressive than what zod provides.

Let's say you have a database and an ORM to talk to it, that ORM has types but no validation. So what do you want: A zod validation over an existing type/class/object.

I think this could be done during runtime, how I am not sure.

m10rten avatar Feb 15 '24 08:02 m10rten

Adding my two cents to this. I also don't see any solution actually being given in issues such as #53.

I don't want Zod to determine what my TypeScript types and interfaces look like, I want my TypeScript types and interfaces to determine what Zod looks like.

I would like:

interface Car {
make: string;
model: string;
}

To lead to:

z.object({ make: z.string(), model: z.string() });

Not the other way around.

As awesome as Zod is, and how eager I was to use it, I put more trust into TypeScript being around and maintained for a long time than I do in Zod. If for any reason I need to move away from Zod in the future, if all my types are inferred through Zod, that becomes a real issue.

As eager as I was to use Zod on my current project, I will probably choose something else instead because of this design choice.

I'd like to add to this that it's not just about trust but also about source of truth being generated by other tools. Today many types get generated from for instance Prisma or GQL schemas. With zod I have no clean mean of ensuring that schema structure is up to date with types that it's supposed to validate. Not only that I can't have the schema implement certain interface but also argument of parse is of type unknown so there is no type safety at all.

sugarp avatar Mar 01 '24 23:03 sugarp

This is still boilerplatey, but what about something like this?

interface Car {
make: string;
model: string;
}

const Car = z.object({
    make: z.string(),
    model: z.string()
}) satisfies ZodType<Car>

playground link

This guarantees that the zod schema you define is in sync with the interface that already exists. I've used this approach a few times when the type I'm modeling comes from some other space I don't control (codegen, upstream libraries, etc.).

schicks avatar Mar 04 '24 20:03 schicks

This is still boilerplatey, but what about something like this?

interface Car {
make: string;
model: string;
}

const Car = z.object({
    make: z.string(),
    model: z.string()
}) satisfies ZodType<Car>

playground link

This guarantees that the zod schema you define is in sync with the interface that already exists. I've used this approach a few times when the type I'm modeling comes from some other space I don't control (codegen, upstream libraries, etc.).

It does, but at the same time you have to redefine your properties that are already set in the interface, but for zod.

m10rten avatar Mar 05 '24 08:03 m10rten

Yeah, as @graup mentioned, I don't think that's a thing zod is going to handle itself; to generate zod parsers from static types you'll need codegen tools like ts-to-zod. My experience is that codegen tools tend to be painful to work with and lead to bad dev experiences, so I tend to opt for the statically checked repetition instead.

That may be because in my cases it's usually not properties that I've already set; if I'm doing this, it's because I'm trying to create a zod parser for a type I don't control. If I did control it, I'd just derive the type from the zod parser.

schicks avatar Mar 05 '24 13:03 schicks

I've used the Joi schema validator before, which has this exact feature - treating your TS types as the source of truth:

import * as Joi from 'joi'

interface Car {
  make: string;
  model: string;
}

const Car = Joi.object<Car>({
    make: Joi.string(),
    model: Joi.string()
})

Truth be told, it doesn't support the inverse (what Zod does).

So, if you need your schema validation library to support defining a schema based on your TS type, you can look for alternatives like Joi.

However, it would be awesome if Zod supported this workflow as well.

Papooch avatar Apr 12 '24 11:04 Papooch

I've used the Joi schema validator before, which has this exact feature - treating your TS types as the source of truth

@Papooch It ensures only that you don't declare additional keys. It doesn't ensure everything else. In your example, Joi is happy with a wrong schema:

const Car = Joi.object<Car>({
  make: Joi.boolean(), // wrong type
  // missing key `model`
});

paleo avatar May 06 '24 09:05 paleo

@paleo My experience is different with version 17.13.1: If I enable the strict type check (which I'm not sure it's false by default), I get a pretty decent type-check.

For example, the following behaves as you describe:

interface Car {
  make: string
  model: string
}

const Car = Joi.object<Car>({
  make: Joi.boolean(),
  model: Joi.string(),
})

However if I switch to:

const Car = Joi.object<Car, true>({ // <--- notice the "true" here
  make: Joi.boolean(),
  model: Joi.string(),
})

gives me a

Type 'BooleanSchema<boolean>' is missing the following properties from type 'StringSchema<string>': alphanum, base64, case, creditCard, and 24 more.ts(2740)

falexandrou avatar May 17 '24 06:05 falexandrou

I found this: https://github.com/jbranchaud/til/blob/master/zod/incorporate-existing-type-into-zod-schema.md The author uses zodType, such as:

const customSchema: z.zodType<CustomType> = z.any();
const schema = z.object({
    custom: customSchema.array();
})

Athrun-Judah avatar May 31 '24 03:05 Athrun-Judah

@Athrun-Judah this will only validate that the key custom maps to an array; it doesn't validate that the elements of the array match CustomType, it just assumes that they do. That might be fine, but is no safer than casting the elements of the array like this;

type CustomType = {a: 'particular shape'}
const schema = z.object({custom: z.any().array()})
const unvalidated = {custom: ['anything', 3, {at: 'all'}, null,undefined]}
const validated: {custom: CustomType[]} = schema.parse(unvalidated)

schicks avatar May 31 '24 15:05 schicks

I wrote a tool https://github.com/ylc395/zodify

You can use this tool to generate schemas from TypeScript types.

ylc395 avatar Jun 24 '24 03:06 ylc395

satisfies z.ZodType<User> seems to be working well, but it doesn't tell you the missing key exactly.

I'm using a mapped type by defining ToZodSchema. It works well with nullable and optional.

type IsNullable<T> = Extract<T, null> extends never ? false : true
type IsOptional<T> = Extract<T, undefined> extends never ? false : true

{
  type T1 = IsNullable<string> // false
  type T2 = IsNullable<string | null> // true
  type T3 = IsNullable<string | undefined> // false
  type T4 = IsNullable<string | null | undefined> // true

  type T5 = IsOptional<string> // false
  type T6 = IsOptional<string | null> // false
  type T7 = IsOptional<string | undefined> // true
  type T8 = IsOptional<string | null | undefined> // true
}

type ZodWithEffects<T extends z.ZodTypeAny> = T | z.ZodEffects<T, unknown, unknown>

export type ToZodSchema<T extends Record<string, any>> = {
  [K in keyof T]-?: IsNullable<T[K]> extends true
    ? ZodWithEffects<z.ZodNullable<z.ZodType<T[K]>>>
    : IsOptional<T[K]> extends true
      ? ZodWithEffects<z.ZodOptional<z.ZodType<T[K]>>>
      : ZodWithEffects<z.ZodType<T[K]>>
}

interface Foo {
  bar: string
  withEffects: string
  nullable: number | null
  optional?: string
  optionalNullable?: string | null
  omitted?: string
}

const schema = z.object({
  bar: z.string(),
  withEffects: z.preprocess(val => val, z.string()),
  // bar: z.number() // Type 'ZodNumber' is not assignable to type 'ZodType<string, ZodTypeDef, string>'.
  // baz: z.number(), // Object literal may only specify known properties, and 'baz' does not exist in type 'Schema<Foo>'.ts(2353)
  nullable: z.number().nullable(),
  optional: z.string().optional(),
  optionalNullable: z.string().optional().nullable(), // should chain optional first
  // omitted: z.string().optional(),
} satisfies ToZodSchema<Foo>) // error: Property 'omitted' is missing in type

jinyongp avatar Jun 27 '24 14:06 jinyongp

any update on this ? how can i use existing interface to construct my zod schema

LeulAria avatar Aug 08 '24 19:08 LeulAria

@LeulAria There have been multiple suggested solutions in this thread. The TL;DR is that this isn't something that zod does but it can be done with other tools.

graup avatar Aug 08 '24 20:08 graup

I think this might work - z.custom

interface IRate {
  id: string;
  name: string;
}

const LocationFormSchema = z.object({
  name: z.string().min(1, { message: 'Name is required and cannot be empty' }),
  rate: z.custom<IRate>().optional(),
});

in this my rate object is defined based on the interface IRate and when assigning a rate in a zod object does not gives any type error

krishnagkmit avatar Aug 21 '24 08:08 krishnagkmit