Repository navigation
Recursive schema through a call-wrapped callback property (lazy(() => Self)) still infers any after #64311 #64420
Description
🔎 Search Terms
recursive inference lazy callback property self-referential object literal 7022 7024 zod
🕗 Version & Regression Information
- Not a regression. Still happens on
main(df1a31e) after Suppress type argument constraint checks in recursive call resolution #64311. - Suppress type argument constraint checks in recursive call resolution #64311 fixed the getter form (
get children() { return array(Self) }) and, as a side effect, the plain callback form (children: () => array(Self)). The form where the callback is wrapped in a call is still broken, and it's the one Zod documents (z.lazy).
⏯ Playground Link
No response
💻 Code
// @strict: true
interface Schema<O> { readonly out: O }
type Shape = Record<string, Schema<any>>;
type Infer<S extends Shape> = { [K in keyof S]: S[K]["out"] };
declare function object<S extends Shape>(shape: S): Schema<Infer<S>>;
declare function array<T extends Schema<any>>(el: T): Schema<T["out"][]>;
declare function string(): Schema<string>;
declare function lazy<T extends Schema<any>>(fn: () => T): T;
// Works since #64311
const G = object({ name: string(), get children() { return array(G); } });
// Still fails
const L = object({ name: string(), children: lazy(() => array(L)) });
const bad: (typeof L)["out"] = { name: "x", children: [{ name: 1, children: [] }] };🙁 Actual behavior
error TS7022: 'L' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer.
error TS7024: Function 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.
L is any, so the wrong nested value in bad isn't reported.
🙂 Expected behavior
L gets the same recursive type as G, and bad reports Type 'number' is not assignable to type 'string'.
Additional information about the issue
checkObjectLiteral types a property assignment eagerly. Getters are the only members it types lazily, which is why the getter form works. When the initializer is a call like lazy(...), typing it means resolving that call, which infers the callback's return type, which reads L while L's own type is still being resolved.
Colin's closed #64248 (commit 5c29a1a) already handled this shape: if a property's initializer is a call or new expression that contains a function body referring to a symbol whose type is currently being resolved, the property is typed lazily through its own symbol, like a getter. That part doesn't depend on the deferred-constraint machinery #64311 replaced. Ported onto current main, it's about 70 lines in checker.go/inference.go, and #64311's recursive-call handling takes care of the constraint side.
Activity
colinhacks commentedon Sep 29, 2026
Same thing with Zod's
z.lazy(). I'm very happy the getter-based inference stuff landed, but a callback-style API (z.lazyort.recursivein io-ts) is another pattern commonly used historically for defining recursive structures.#64426 fixes it against the published zod package.