zod icon indicating copy to clipboard operation
zod copied to clipboard

$ZodPipe: Type instantiation is excessively deep

Open lo1tuma opened this issue 6 months ago • 7 comments

I’m using 3.25.50 and get a Type instantiation is excessively deep and possibly infinite. error when defining a recursive type using $ZodPipe.

type Foo = $ZodPipe<Bar, $ZodTransform>; // Type instantiation is excessively deep and possibly infinite.
type Bar = $ZodString | Foo;

with other zod types this works fine, e.g. with $ZodArray

type Foo2 = $ZodArray<Bar2>; // no error
type Bar2 = $ZodString | Foo2;

lo1tuma avatar Jun 03 '25 22:06 lo1tuma

Did this start with 3.25.50? I need to decide whether to revert #4599 if it's causing a lot of issues for people.

colinhacks avatar Jun 03 '25 23:06 colinhacks

No, I also had this issue with 3.25.42.

lo1tuma avatar Jun 03 '25 23:06 lo1tuma

I hope not. So far it looks like it solved all my issues.

gak10100 avatar Jun 04 '25 02:06 gak10100

@gak10100 Great to hear

colinhacks avatar Jun 04 '25 05:06 colinhacks

This one is even harder than the others 🙃

Here's a simpler reproduction:

type Foo = z.ZodPipe<Foo>;

I know which lines of code are causing this, but they're important—it relates to how Zod 4 tracks which schemas are considered optional inside of objects. The optionality of the ZodPipe's input schema "bubbles up" to the ZodPipe itself, and something about this causes the circularity during typechecking.

The same limitations apply to any schema that relies on this "bubbling" behavior: ZodNullable, ZodOptional, ZodDefault, ZodPrefault, ZodNonOptional, ZodCatch, ZodReadonly, and ZodLazy. I could work around a few of these but it's a real shame this bubbling pattern doesn't work with this. It's an elegant pattern, and the alternatives are quite messy by comparison.

I'm curious how many people will actually be impacted by this limitation.

colinhacks avatar Jun 04 '25 05:06 colinhacks

Interesting. FWIW in Zod 3 this worked fine for all kind of "wrapper" types like ZodNullable, ZodEffect etc.

lo1tuma avatar Jun 04 '25 15:06 lo1tuma

I’ve looked a bit into the code. Unfortunately my attempt by trying to convince typescript to lazily evaluate the type didn’t fix the issue.

type ForceEvaluation<T> = T extends infer U ? U : never;

export interface $ZodNullableInternals<T extends SomeType = $ZodType>
  extends $ZodTypeInternals<core.output<T> | null, core.input<T> | null> {
  def: $ZodNullableDef<T>;
  optin: T extends { _zod: { optin: infer Prop } } ? ForceEvaluation<Prop> : undefined,
  optout: T extends { _zod: { optout: infer Prop } } ? ForceEvaluation<Prop> : undefined,
  isst: never;
  values: T extends { _zod: { values: infer Prop } } ? ForceEvaluation<Prop> : undefined,
  pattern: T extends { _zod: { pattern: infer Prop } } ? ForceEvaluation<Prop> : undefined,
}

lo1tuma avatar Jun 18 '25 20:06 lo1tuma

Yeah this isn't really possible to solve with the current internals structure. The current structure is incredibly elegant and avoids a lot of complexity/fragility/limitatations that were present in Zod 3. This issue is a bummer, but worth the cost imo. Not planning to pursue a fix here.

colinhacks avatar Jul 08 '25 05:07 colinhacks

I'm not sure if this will help with your specific issue, but I came here searching for solutions to "excessively deep and possibly infinite" problems and what fixed it for me was making sure that I was using the same exact zod version throughout my monorepo / project.

Some dependencies were still holding on to an older version in the lockfile, and my theory is that differences in internal typing between the v4 updates make TypeScript work harder if it is trying to resolve types for multiple versions of the package as they cross paths in my main project.

After I pinned the version using "overrides" in package.json, all of those "excessively deep" errors went away for me.

msheakoski avatar Jul 08 '25 14:07 msheakoski

The current structure is incredibly elegant and avoids a lot of complexity/fragility/limitatations that were present in Zod 3.

I would love to understand more about this structure. Can I ask you to point to the particular lines of code causing this issue?

In my case I’ve tried to bypass this with @ts-expect-error comments (see e.g. here). But it also causes seemingly randomly issues on the consumer-side of my library (see e.g here).


what fixed it for me was making sure that I was using the same exact zod version throughout my monorepo / project.

@msheakoski thanks, I’ve double checked that using npm ls zod, but I have only one zod version in my dependency tree, so this is not causing the issue for me.

lo1tuma avatar Jul 13 '25 19:07 lo1tuma

Hi @lo1tuma! I'm Dosu and I’m helping the zod team.

Absolutely, I can point you to the core of this structure. The recursion issue with ZodPipe and similar wrappers in Zod 4 comes from how optionality (optin/optout) is "bubbled up" through wrapper types. This is implemented in the *Internals types for wrappers like $ZodPipe, $ZodNullable, and $ZodOptional in packages/zod/src/v4/core/schemas.ts.

For example, see the $ZodPipeInternals interface and the $ZodPipe constructor, where properties like optin and optout are defined in terms of the wrapped schemas:

export interface $ZodPipeInternals<A extends SomeType = $ZodType, B extends SomeType = $ZodType>
  extends $ZodTypeInternals<core.output<B>, core.input<A>> {
  def: $ZodPipeDef<A, B>;
  isst: never;
  values: A["_zod"]["values"];
  optin: A["_zod"]["optin"];
  optout: B["_zod"]["optout"];
}

And in the constructor:

util.defineLazy(inst._zod, "optin", () => def.in._zod.optin);
util.defineLazy(inst._zod, "optout", () => def.out._zod.optout);

This pattern is repeated for $ZodNullable and $ZodOptional. The benefit is that it enables uniform introspection and advanced features, but it can force TypeScript to evaluate infinite type recursions when wrappers are nested recursively, leading to the "type instantiation is excessively deep" error. This is a tradeoff: Zod 3 didn't propagate this metadata as deeply at the type level, so it was less prone to these recursion issues but also less featureful and less consistent for advanced use cases. You can see the relevant code and pattern in core/schemas.ts and classic/schemas.ts (source).

Your use of @ts-expect-error is understandable, but as you've noticed, it can cause fragile or unpredictable issues for consumers, especially as their code interacts with the recursive types and TypeScript's inference system.

If you need to maintain recursive types with wrappers, using z.lazy() can sometimes help by deferring type evaluation, but the underlying issue is a TypeScript type system limitation that's exacerbated by Zod 4's metadata propagation strategy. For more on this, see the discussion and code examples in this PR and test file.

If this answers your question, feel free to close the issue! If you need more details or want to discuss workarounds, let me know.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other  Join Discord Share on X

dosubot[bot] avatar Jul 21 '25 21:07 dosubot[bot]

I’ve tested this with the typescript 5.9 RC version and see now additional errors like Type of property 'input' circularly references itself in mapped type 'Required<$ZodArrayInternals<FieldSchema>>'. [2615].

lo1tuma avatar Jul 29 '25 13:07 lo1tuma