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 no way to describe recursive data. The schema must reference itself, and a getter is the only way to write it, because the type has no name until the declaration finishes.

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

That does not infer. The declaration collapses 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 being asked for is the type under inference, so there is nothing the author can write.

What a library does instead

colinhacks/recursive-inference holds the same miniature schema library written twice. Both declare the same five recursive schemas.

idiomatic.ts threads output and input as ordinary type parameters, constrains against the real base interface, and reads them back with an indexed access. It reports 11 of the errors above on TypeScript 7, and the same 11 on 5.9.3 and 5.5.4.

shipped.ts is the same library with the workarounds Zod ships. It compiles. The constraint becomes a structural type that discards the parameters, and reading an output type becomes a conditional that defers.

// 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 applies that substitution at 296 sites. A constraint written as SomeType accepts anything with the right shape of internals, so across most of its own surface the library cannot state what a schema is. It opts out of the checking it exists to provide, on itself, to keep inference working for the people using it.

Past one library

Recursive data is ordinary. A category tree, a comment thread, a filesystem node, a self-referencing foreign key, a state machine whose transitions name earlier states, an agent whose handoffs name other agents. Any library that hands the user a static type derived from a runtime description of that data meets this wall, and the seams are visible across the ecosystem once you know the shape.

They show up in two places. Zod pays inside the library, at those 296 sites, so its users never see it.

Drizzle pays at the call site. A self-referencing foreign key needs the user to write the return type by hand:

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

Without that annotation the arrow's return type is inferred from a table that is still being declared, which is this same failure. The idiom runs through Drizzle's own tests and docs, and AnyPgColumn is the same kind of widened stand-in as Zod's SomeType — a type that says "some table's column" because the real one cannot be named yet.

The reason this seems worth raising on its own is that neither cost is visible from outside. Zod infers recursive schemas today and Drizzle links self-referencing tables today, so from a user's perspective both features work and there is nothing to report. What is actually happening is that each library has given up part of its type surface, separately, to route around the same limitation.

Scope

I can't judge how large the general problem is. #64172 is one concrete piece of it: it makes the case above infer, and with it Zod's 296 substitutions can all be deleted. That PR is measured and has tests, but it is a starting point rather than a general answer, and a design that covers more of this would be worth more than the specific fix.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions