Skip to content

Recursive inference through self-referential object literals #64192

Description

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 category has no type until its own declaration finishes.

const category = object({
  name: string(),
  get subcategories() {
    return array(category);
  },
});

That doesn't infer. Both declarations collapse to any:

error TS7022: 'category' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer.
error TS7023: 'subcategories' implicitly has return type 'any' because it does not have a return type annotation and is referenced directly or indirectly in one of its return expressions.

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.

// idiomatic: the constraint says what a schema is, and the read is direct
export type output<T extends $ZodType> = T["_zod"]["output"];
export interface $ZodArray<T extends $ZodType = $ZodType> extends $ZodType<output<T>[], input<T>[]> {}

// what ships: the constraint discards the parameters, and the read defers
export type SomeType = { _zod: _$ZodTypeInternals };
export type output<T> = T extends { _zod: { output: any } } ? T["_zod"]["output"] : unknown;
export interface $ZodArray<T extends SomeType = $ZodType> extends $ZodType {
  _zod: $ZodArrayInternals<T>;
}

Zod does this at 296 sites so that recursive schemas infer for users. The SomeType constraint 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:

reportsTo: integer('reports_to').references((): AnyPgColumn => employees.id),

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. The AnyPgColumn type is the same kind of widened stand-in as Zod's SomeType. 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.

Activity

  1. colinhacks commented on Sep 9, 2026

    @colinhacks
    Author

    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!

  2. devanshj commented on Sep 9, 2026

    @devanshj

    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..

    Image

    I keep saying there isn't a problem T extends F<T> pattern and dependent contextual inference can't solve :P

  3. devanshj commented on Sep 10, 2026

    @devanshj

    Actually 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...

    Image

    And the implementation also works...

    Image
  4. added
    Possible ImprovementThe current behavior isn't wrong, but it's possible to see that it might be better in some cases
    and removed
    Needs InvestigationThis issue needs a team member to investigate its status.
    on Sep 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Possible ImprovementThe current behavior isn't wrong, but it's possible to see that it might be better in some cases

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions