Repository navigation
Recursive inference through self-referential object literals #64192
Description
Activity
- addedNeeds InvestigationThis issue needs a team member to investigate its status.This issue needs a team member to investigate its status.
on Sep 8, 2026 Cross posting for context: #64172
I consider this to be one of the highest impact things for tsc to finally crack. Despite the predicable limitations of my Claude attempt (many far worse attempts preceded what I finally ended up posting here) I think something along these lines:
a) is a perfect "hard problem" that could be cracked with sufficient resources and human judgement, and
b) would be a huge unlock for the ecosystem: ORMs, database schema modeling libraries, state machines, agent frameworks, etc could represent cyclical data & workflows in a cleaner way.Currently Zod 4 is basically the only library that supports it, but it was only possible by loosening type safety on several Zod's functions (to defer instantiation on inputs):
z.object({ asdf: "not a schema" // no compiler error })
Placing proper constraints on the inputs to
z.object()(e.g.Record<string, ZodType>) would force premature resolution and make recursive inference impossible. I would very much like to see the solution here that avoids these tradeoffs. 👍 Thanks again!Fwiw #64091 can fix this with a little change in the api (assuming that's acceptable)...
declare const z: { string: () => { "~type": string }, object: { // the usual <T extends Record<string, { "~type": unknown }>> (t: T): { "~type": { [K in keyof T]: T[K]["~type"] } } // special overload for recursive usage <T extends (self: { "~type": { [K in keyof ReturnType<T>]: ReturnType<T>[K]["~type"] } }) => Record<string, { "~type": unknown }>> (t: T): { "~type": { [K in keyof ReturnType<T>]: ReturnType<T>[K]["~type"] } } } } // works easy const basic = z.object({ name: z.string() }) // doesn't work const recursive = z.object({ name: z.string(), get child() { return recursive } }) // works if #64091 is fixed const recursiveFixed = z.object(self => ({ name: z.string(), get child() { return self } })) // MyNode is a recursive type type MyNode = (typeof recursiveFixed)["~type"]
Y'all can checkout PR #64092 and try it yourself..
I keep saying there isn't a problem
T extends F<T>pattern and dependent contextual inference can't solve :PActually I realized the solution doesn't even have to be coupled to the library (and doesn't even need an api change) we can just have a generic utility like this...
const createCircular = <F extends ((t: () => ReturnType<F>) => unknown)>(f: F) => { let t = f(() => t) as ReturnType<F> return t } // works with #64091 const zNode = createCircular(self => z.object({ name: z.string(), get child() { return self() } }) )
And that just works...
And the implementation also works...

- addedPossible ImprovementThe current behavior isn't wrong, but it's possible to see that it might be better in some casesThe current behavior isn't wrong, but it's possible to see that it might be better in some casesand removedNeeds InvestigationThis issue needs a team member to investigate its status.This issue needs a team member to investigate its status.
on Sep 17, 2026
A library that derives static types from a runtime description of data has to let a schema reference itself, or it can't describe recursive data. The only place to put that self-reference is a getter, because
categoryhas no type until its own declaration finishes.That doesn't infer. Both declarations collapse to
any:The annotation the error asks for is the type that inference was supposed to produce, so there is nothing the author can write in its place.
The workaround
colinhacks/recursive-inference has two copies of a small schema library. One is written the normal way and one is written the way Zod actually does it. Both declare the same five recursive schemas.
The normal version,
idiomatic.ts, takes output and input as ordinary type parameters, constrains every parameter against the real base interface, and reads the output type back with an indexed access. It produces 11 of the errors above on TypeScript 7, and the same 11 on 5.9.3 and 5.5.4.The other version,
shipped.ts, has the workarounds Zod ships. It compiles. The constraint is replaced with a structural type that doesn't mention the parameters, and the output type is read through a conditional type so the lookup is deferred.Zod does this at 296 sites so that recursive schemas infer for users. The
SomeTypeconstraint accepts anything with the right shape of internals, so at those sites Zod can't constrain a parameter to be a schema.Other libraries
Recursive data is common: category trees, comment threads, filesystem nodes, self-referencing foreign keys, state machines whose transitions name other states, agents whose handoffs name other agents. Any library that derives a static type from a runtime description of that data hits the same problem.
Zod absorbs the cost inside the library, at those 296 sites, so users don't see it.
Drizzle puts it on the user. A self-referencing foreign key needs a hand-written return type:
Without the annotation the arrow's return type is inferred from
employees, which is still being declared, and that is the same failure. The annotation appears throughout Drizzle's own tests and docs. TheAnyPgColumntype is the same kind of widened stand-in as Zod'sSomeType. It means some column of some table, because the real column type can't be named yet.Neither cost shows up as a bug report. From the outside both features work; Zod infers recursive schemas and Drizzle links self-referencing tables.
Scope
I don't know how big the general problem is. #64172 fixes the case above, and with it all 296 of Zod's substitutions can be deleted. It has tests and measurements, but it is a fix for one shape, not a general design. A design that covers more of this would be better than the specific fix.