zod icon indicating copy to clipboard operation
zod copied to clipboard

Inferred recursive types

Open cshaa opened this issue 2 years ago • 3 comments

I'm working on a library that needs type definitions at runtime, and I wanted to use Zod as it's the “lingua franca” of runtime type definitions. However, my library will make heavy use of recursive types, and working with these is currently very cumbersome in Zod. The Recursive types section of the documentation reads:

You can define a recursive schema in Zod, but because of a limitation of TypeScript, their type can't be statically inferred. Instead you'll need to define the type definition manually, and provide it to Zod as a "type hint". Unfortunately this code is a bit duplicative, since you're declaring the types twice: once in the interface and again in the Zod definition.

I wonder if such a limitation is necessary, since TypeScript supports recursion even on anonymous types. For example, if we define the following helper types...

const TAG = Symbol('TAG');
type Tag<N extends string> = { [TAG]: N };
type RecursiveType<Name extends string, Self extends object, O = Self>
    = O extends Tag<Name> ? RecursiveType<Name, Self>
    : O extends Array<infer V> ? Array<RecursiveType<Name, Self, V>>
    : O extends object ? { [K in keyof O]: RecursiveType<Name, Self, O[K]> }
    : O;

... we can create the Category type without having its definition reference itself:

type Category = RecursiveType<'Cat', {
  name: string;
  subcategories: Tag<'Cat'>[];
}>;

const x: Category = { name: "foo", subcategories: [42] }; // error
const y: Category = {
  name: "bar",
  subcategories: [
    { name: "qux", subcategories: [] }
  ]
}; // ok

Link to TS Playground.

It is then hard to imagine that Zod couldn't support recursive types with full static inference using an API like this:

const Category = z.label('Cat', z.object({
  name: z.string(),
  subcategories: z.ref('Cat').array(),
}));

Is there a good reason why this isn't a part of Zod already? Or should I try to make a PR for this?


EDIT: Changed the last code snippet, so that it uses methods z.label and z.ref instead of z.recursiveType and z.tag, becaue I think the new methods are easier to understand.

cshaa avatar Dec 13 '22 23:12 cshaa

In your examples, the type is not being inferred or extracted from a self-referencing runtime construct. It's trivially easy to write a recursive type. I didn't fully follow your code but this is all you actually need:

type Cat = {
  name: string;
  subcategories: Cat[];
};

I'm very dissatisfied with this limitation of Zod though, and overcoming this will likely be a major design goal of a hypothetic Zod 4. Anyway - you're certainly welcome to attempt a PR but I'm pretty confident this is not possible atm.

colinhacks avatar Dec 14 '22 20:12 colinhacks

I'm sorry for not being clear enough. What I wanted to say was that it is possible to declare a self-referencing schema whose TS type is correctly inferred.

While TS can't infer if from the z.lazy pattern, it works if we introduce the label-ref pattern that I propose. The types I've written about in OP are precisely the type constructs that make it possible.

>>> See my minimal implementation on the TS playground. <<<

In my proposal, the following code:

const Category = z.label('Cat', z.object({
  name: z.string(),
  subcategories: z.ref('Cat').array(),
}));

would be the dry alternative to this code:

interface Category {
  name: string;
  subcategories: Category[];
}

const Category: z.ZodType<Category> = z.lazy(() =>
  z.object({
    name: z.string(),
    subcategories: z.array(Category),
  })
);

I'm pretty confident this is not possible atm.

I hope to have proven you wrong and look forward to further collaboration :^)

cshaa avatar Dec 15 '22 22:12 cshaa

I think I found something even better. It's possible to have static inference even with the z.lazy pattern, if we modify it slightly. The following code would do the same as the two snippets in my previous comment:

const Category = z.lazy(self => z.object({
  name: z.string(),
  subcategories: self.array(),
}));

See it on the TS playground!

The key here is the self argument, which does all the heavy lifting. While at runtime it would hold the exact same value as the Category variable, in the TypeScript's type system it has a special non-transparent type, so that it's still possible to statically infer the return type of the lambda.

cshaa avatar Dec 16 '22 19:12 cshaa

I think this is a really cool idea. Do you think you would be able to make a PR?

JacobWeisenburger avatar Dec 25 '22 13:12 JacobWeisenburger

I started working on it, but then got stuck on the fact that Zod types have both a _def property and In & Out type parameters, ie. the actual resulting type is described in multiple places. I finished the transformer for _def, but I haven't implemented the transformers for In & Out yet.

However, I realized that this makes Zod quite cumbersome for my original use case¹ – therefore I won't be using Zod in my codebase and cannot guarantee I'll find time to finish the PR. If anybody wants to tackle this in the meantime, here's my work in progress.

¹) I need to do a lot of deep mapping on the runtime types, and this doesn't go well with the fact that Zod schemas have three different places where they store type information (_def, In, Out).

cshaa avatar Jan 11 '23 20:01 cshaa

thanks for your honesty and all your hard work.

JacobWeisenburger avatar Jan 11 '23 21:01 JacobWeisenburger