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