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.
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.
That does not infer. The declaration collapses to
any: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.tsthreads 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.tsis 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.Zod applies that substitution at 296 sites. A constraint written as
SomeTypeaccepts 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:
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
AnyPgColumnis the same kind of widened stand-in as Zod'sSomeType— 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.