Skip to content

Type a recursive call-initialized object literal property lazily - #64426

Open
ethan (ethndotsh) wants to merge 3 commits into
microsoft:mainfrom
ethndotsh:lazy-call-initialized-property
Open

ethan (ethndotsh) wants to merge 3 commits into
microsoft:mainfrom
ethndotsh:lazy-call-initialized-property

Conversation

@ethndotsh

@ethndotsh ethan (ethndotsh) commented Sep 24, 2026 •

Copy link
Copy Markdown

Fixes #64420

This patch was written with Claude Code (Opus 5.5), porting Colin McDonnell (@colinhacks)' commit 5c29a1a from #64248 onto the architecture from #64311. I've read it, understand it, and will be the one handling review.

Analysis

After #64311, this works:

const G = object({ name: string(), get children() { return array(G); } });

This, the shape Zod documents with z.lazy, doesn't:

const L = object({ name: string(), children: lazy(() => array(L)) });
// TS7022: 'L' implicitly has type 'any' ...
// TS7024: Function implicitly has return type 'any' ...

checkObjectLiteral types every property assignment eagerly; only accessors go through checkNodeDeferred and get typed later through their own symbol. Typing children means resolving lazy(...), which infers the arrow's return type, which reads L while L's type is still being resolved. The circularity is reported before #64311's recursive-call handling ever gets involved.

Fix

If a property assignment's initializer is a call or new expression, and a function body somewhere inside it refers to a symbol whose type is currently being resolved (per typeResolutions), the property is treated like an accessor: checkObjectLiteral adds it to the literal by its declaration symbol and defers it with checkNodeDeferred, and its type comes from getTypeOfSymbol when something asks for it. Two guards keep it lazy:

  • getWidenedProperty leaves it alone, as it does for accessors.
  • getTypeOfConcretePropertyOfContextualType returns no contextual type for it while its type is unresolved, since the only type on offer is the literal's own inferred type.

When that deferred type is eventually resolved and re-enters the outer object(...) call, #64311 skips the constraint checks on the recursive resolution, so the cycle ends. The outermost call still checks constraints.

This is 5c29a1a minus the parts of #64248's design that #64311 made unnecessary: the hasObjectLiteralAccessors hook that decided when to defer constraint checks, and the spread-getter check from another commit in that PR. The plain callback-property form (children: () => array(node), 15bb626 in #64248) needs no change; it already infers correctly on main after #64311.

The only shapes this triggers on are a callback that sits inside a call or new expression initializing a property, and that refers to a declaration still being typed. Code that runs immediately is left alone: references outside a function body, inside an immediately invoked function, or in a function's computed name or decorators don't count, matching isUsedInFunctionOrInstanceProperty. Those cases keep their existing errors (covered in the test).

Tests (from #64248, adapted):

No existing baselines change.

Copilot Checklist

I successfully ran the applicable command at the end of my session, and it completed without error:

  • npx hereby validate
  • npx hereby validate --api (for TypeScript API changes): not applicable

🤖 Generated with Claude Code

ethan (ethndotsh) and others added 2 commits September 24, 2026 01:05
Adapted from microsoft#64248. Baselines show the current behavior: a property
initialized by a call such as lazy(() => array(Self)) makes Self
implicitly any (TS7022/TS7024).

Co-Authored-By: Colin McDonnell <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
When a property assignment's initializer is a call or new expression
containing a function body that refers to a symbol whose type is still
being resolved, treat the property like an accessor: defer it and type
it on demand through its own symbol. Widening leaves it alone and it
gets no contextual type from itself while unresolved. The recursive
re-entry into the outer call is then handled by the constraint-check
suppression from microsoft#64311.

Ported from 5c29a1a in microsoft#64248, minus the deferred-constraint hooks that
microsoft#64311 made unnecessary.

Fixes microsoft#64420

Co-Authored-By: Colin McDonnell <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Copilot AI balanced review requested due to automatic review settings September 24, 2026 16:23
@typescript-automation typescript-automation Bot added the For Uncommitted Bug PR for untriaged, rejected, closed or missing bug label Sep 24, 2026
@ethndotsh

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The traversal can incorrectly defer synchronously executed IIFEs and computed property names, suppressing use-before-declaration errors.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
What changed in this PR

Adds lazy typing for recursive call-initialized object-literal properties, enabling patterns such as lazy(() => Self).

Changes:

  • Detects recursive callback references and defers property typing.
  • Preserves widening and contextual-typing behavior for deferred properties.
  • Adds compiler, declaration-emit, and hover-order regression coverage.
File Description
tsc/​internal/​checker/​checker.go Defers qualifying properties and adds resolution guards.
tsc/​internal/​checker/​inference.go Detects references to symbols currently being resolved.
tsc/​internal/​fourslash/​tests/​hoverThenDiagnosticsRecursiveCallbackArgument_test.go Tests hover/diagnostic ordering.
tsc/​testdata/​tests/​cases/​compiler/​recursiveCallbackArgumentConstraint.ts Covers recursive inference and diagnostics.
tsc/​testdata/​tests/​cases/​compiler/​recursiveCallbackArgumentDeclarationEmit.ts Covers recursive declaration emit.
tsc/​testdata/​baselines/​reference/​compiler/​recursiveCallbackArgumentConstraint.errors.txt Expected diagnostics baseline.
tsc/​testdata/​baselines/​reference/​compiler/​recursiveCallbackArgumentConstraint.symbols Expected symbols baseline.
tsc/​testdata/​baselines/​reference/​compiler/​recursiveCallbackArgumentConstraint.types Expected types baseline.
tsc/​testdata/​baselines/​reference/​compiler/​recursiveCallbackArgumentDeclarationEmit.js Expected emit and declaration baseline.
tsc/​testdata/​baselines/​reference/​compiler/​recursiveCallbackArgumentDeclarationEmit.symbols Expected declaration-test symbols.
tsc/​testdata/​baselines/​reference/​compiler/​recursiveCallbackArgumentDeclarationEmit.types Expected declaration-test types.

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment thread tsc/internal/checker/inference.go Outdated
Comment on lines +1435 to +1438
case ast.IsFunctionLike(node):
inBody = true
}
return node.ForEachChild(func(child *ast.Node) bool { return visit(child, inBody) })
An immediately invoked function runs while the initializer is evaluated,
and so do a function's computed name and decorators. References there are
not deferred, so they no longer make the property lazy; this matches
isUsedInFunctionOrInstanceProperty. Without this, a computed method name
reading the declaration being typed lost its circularity error.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

This branch has not been deployed

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

Labels

For Uncommitted Bug PR for untriaged, rejected, closed or missing bug

Projects

Status: Not started

Development

Successfully merging this pull request may close these issues.

Recursive schema through a call-wrapped callback property (lazy(() => Self)) still infers any after #64311

2 participants