Skip to content

Infer recursive types through object literal getters - #64172

Open
Colin McDonnell (colinhacks) wants to merge 9 commits into
microsoft:mainfrom
colinhacks:recursive-getter-both-rebased
Open

Infer recursive types through object literal getters#64172
Colin McDonnell (colinhacks) wants to merge 9 commits into
microsoft:mainfrom
colinhacks:recursive-getter-both-rebased

Conversation

@colinhacks

@colinhacks Colin McDonnell (colinhacks) commented Sep 4, 2026

Copy link
Copy Markdown

Fixes #62181. Fixes #62180. Refs #64192.

The problem

const node = object({
  name: text(),
  get children() {
    return array(node);
  },
});

On main, TS7022 on node and TS7023 on children. With this change, neither, and sample.children[0].children[0].name is string. The hover in #62181 goes from (accessor) parent: any to the recursive type.

Issue #62180 comes from the same place. There the symptom is TS2741: Property 'out' is missing on an inherited member. Both are fixed here; fixing either alone makes the other worse.

Cause

Before it walks base types, resolveObjectTypeMembers publishes the self-declared member table as a recursion guard. Inside that window the inherited members are missing. Two defects follow and need opposite answers.

Inference. Constraint verification of an inferred candidate walks every source property. An un-annotated getter infers its type from its body, so walking it re-enters a declaration that is still being resolved. That circularity is an artifact of the comparison. It still gets reported and cached.

Publication. A lookup that lands in the window reads an inherited member as absent and acts on that.

The change

On entry, compareProvisionally marks typeResolutions. When pushTypeResolution finds a cycle that starts below that mark it raises a private sentinel. The forcing site, tryGetTypeOfMember, catches it, restores the checker stacks and reports the member unanswerable.

Nothing computed from the circular value completes, so there is no diagnostic to suppress, no placeholder, no cache write to journal and nothing to retract. An earlier draft did all of that and ran 513 lines.

Two things follow from the unwind, each with a test that fails without it.

  • A comparison that skipped an unanswerable member may keep a candidate but never reject one, and its success is not written to the relation cache, because that cache outlives the state the skip depended on.
  • Abandoning inside the publication window would strand MembersResolved on a type that holds only its own members, so the flags are cleared on the way out, in resolveStructuredTypeMembers.

For the publication defect, resolveObjectTypeMembers pushes the bases it is about to inherit, and a miss in the window resolves against those, returning the member the table is about to hold. Re-entry through the type in flight finds its record marked consulting and falls back to the published table, the same view the inheritance loop has.

The provisional-depth gate

A miss in the window has two correct answers depending on the caller. Inside a provisional comparison, completing the lookup forces the type under question. Outside one, a miss is just early. So the completed lookup is gated on provisionalDepth == 0. Inside a provisional region the narrower suppression still applies. A getUnmatchedProperty miss means "not yet", but only for a name some base declares, and that check (mayInheritProperty) reads declaration tables without forcing a member type. It is not entirely inert, since resolving a class's base list can force its heritage expression, but that is the resolution the window is already inside and the resolution stack guards it.

On a corpus of probe files reducing the shapes this affects, counting the errors that demand an annotation the user cannot write (TS7022, TS7023, TS2502):

lines errors demanding an annotation
main 76
publication fix alone 59 78
inference fix alone 269 0
both, ungated, one answer everywhere 292 5
both, ungated, suppression restored 327 3
this PR 331 0

The publication fix alone is a small net negative on inference. Ungated, it reintroduces the collapse on plain mutual recursion (get posts() { return array(post); }) in three files, because completing the lookup forces posts mid-inference. Dropping the suppression instead costs fifteen.

Tests

Fifteen conformance cases with baselines cover the recursive type and the shapes it is written in, mutual recursion, the postponed constraint and the same constraint split across two files, declaration emit, union recursion in both member forms, reverse mapped inference, and a stricter variant of #62180's shape. The fourslash fixture is #62180's code verbatim.

Five behave identically to main and pin that. Overload resolution across an unresolved accessor in generic and plain signatures, a skipped accessor's constraint still reported, a genuinely absent property still absent, and the same on the speculative path. Each caught a real defect during development.

The flow-loop case, recursiveTypeThroughObjectLiteralGetterFlowLoop.ts, pins the rollback. A getter body that runs a loop and assigns to a union-typed variable forces the inner getter from a loop back-edge. Since checkExpressionCachedEx swaps in a nil flowLoopStack and restores it only on normal return, an unwind that crosses it has to restore the saved stack rather than re-slice the replacement. Get that wrong and the checker crashes.

The mutual case, recursiveTypeThroughObjectLiteralGetterMutual.ts, pins the gate, and nothing else in the suite reaches it. It needs two schemas naming each other through getters and two key remappings over the same shape, one per variance side, each keyed on a different member of the internals. With a single remapping every candidate in the table passes. Thirteen hand-written cases and the corpus all missed this; the case is reduced from a real failure.

Every case asserting a resolved type also names an absent key behind a @ts-expect-error, which reports as unused on main.

Five fourslash cases. Two are #62181, including a check that diagnostics are unchanged whether or not the hover was requested first. Three pin postponed-constraint reporting across open orders, one without opening the file that carries the error.

Cost

All 63 packages under go test ./internal/... pass, and no reference baseline moves beyond the cases added here.

Code that does not hit the pattern is unchanged. The --extendedDiagnostics counters are reproducible run to run.

main this change
SolidJS, own tsconfig.json — Symbols / Types / Instantiations 172,602 / 829,217 / 1,235,646 identical
fumadocs, packages/core 263,300 / 60,583 / 212,832 identical

On the probe corpus, which does hit it:

main this change
Symbols 39,234 40,039 (+2.1%)
Types 8,403 9,283 (+10.5%)
Instantiations 14,281 19,062 (+33%)

Mostly types that collapsed to any now resolving.

Wall-clock differences are below what this hardware resolves. Interleaved runs on the inference-heavy projects give effects that change sign between runs, and same-binary control arms move as far as the effect.

498 projects compiled on both compilers and compared on full diagnostic text, each compiled twice on main first because three or four are nondeterministic there.

identical diagnostics 496
differ 1
excluded as nondeterministic on main 4

The one difference is the probe corpus, where the declarations that demanded an annotation on main now infer.

Effect on a library

Zod substitutes a deliberately vague constraint for the real one at 296 sites, because the real constraint does not survive inference through a getter and the schema collapses to any.

This commit deletes all 296 and restores the intended constraint at every site. Under main the library then reports 189 errors: 167 are declarations demanding an annotation, getters demanding a return type, and the constraints that fail once those collapse. With this change all 167 are gone. The 22 left are unused symbols and a missing @types/node in that checkout. Its recursive schemas cover recursion through a union, a union of one and a discriminated union, mutual recursion, recursive tuples, cyclic data and z.lazy.

Scope

The unwind is reachable only from a provisional comparison. The 498-project result and the unchanged counters above bear that out.

One shape is not fixed; a case pins it. Where the parameter is a mapped type over the inferred one, so inference runs through a reverse mapped type, neither this change nor main resolves the declaration. The reverse-mapped case, recursiveTypeThroughObjectLiteralGetterReverseMapped.ts, asserts both report the same thing, since a provisional comparison explores further than an ordinary check and must not turn that into an extra diagnostic.

Beyond that I know of no remaining failure, measured against the conformance and fourslash suites, 498 projects, and Zod with every workaround removed.


Disclosure: this patch was authored with AI assistance (Claude Code). I have read and understood the result and will discuss and revise it in review.

Inferring a type argument verifies the candidate against its constraint. That check reports nothing and
only decides whether to keep the candidate, but it walks every source property, and for an un-annotated
getter that means inferring from its body -- which re-enters the declaration being resolved.

Mark the resolution stack when the constraint check opens. A cycle that reaches below the mark was
caused by the question rather than by the program, so the attempt is abandoned at the point that forced
the member and the member is reported as unanswerable. Nothing computed from the circular value ever
completes, so there is no diagnostic to suppress, no placeholder to hand out, no cache write to journal
and nothing to retract.

Two things the unwind exposes. A comparison that passed over an unanswerable member may keep the
candidate but must not reject one, and its success is not written to the global relation cache. And
resolveObjectTypeMembers publishes a partial member table as its own recursion guard: abandoning inside
that window would leave the type marked resolved while holding only its self-declared members,
permanently, for every later reader -- so the flags are cleared on the way out.
Twelve compiler cases and five fourslash cases, carried over from the earlier implementation of this
fix so the two are held to the same evidence.

One baseline differs from that implementation and the difference is deliberate. A postponed constraint
is now reported at the member that violates it -- `TS2741: Property 'out' is missing` -- rather than at
the enclosing shape through a three-level assignability chain. All three file orderings agree, so the
order-independence the fourslash cases exist to pin is unaffected.
A provisional comparison explores further than an ordinary check does, so it can walk into a circular
base constraint that main never reaches -- main collapses the getter first and stops. Reporting that
circularity, or caching it as the type parameter's resolved constraint, adds a TS2313 to a program main
accepts.

The region reports nothing and decides nothing by construction, so the circularity is the question's
own: it neither reports nor sticks, and the next ask outside any region is free to reach and report a
real one. The new case pins that this variant, which neither main nor this change resolves, reports the
same thing on both.
resolveObjectTypeMembers publishes the self-declared member table before it walks the base types, so
every inherited member reads as absent until the loop adds it. A miss in that window has two correct
answers, and the previous commit merged them into one.

Inside a provisional comparison the asker is a speculative check whose whole job is to find out
whether a member can answer yet, so completing the lookup there forces the exact type the question is
about. Outside one there is no question in flight and a miss is just a lookup that arrived early, so
finishing it against the bases still to be inherited is what keeps the window unobservable.

Gate the completed lookup on provisionalDepth == 0 and restore the narrower suppression for the
inside-a-region case. Measured on a schema-library corpus, counting the getter-collapsed-to-any
diagnostic this series exists to remove: one answer everywhere gives 5, restoring the suppression
without the gate gives 3, dropping the suppression and gating gives 12, and both together give 0 --
which is what the inference fix gives on its own, so microsoft#62180 now closes at no cost to microsoft#62181.

Add recursiveTypeThroughObjectLiteralGetterMutual.ts, which is the only case in the suite that
reaches this. It needs two schemas naming each other through getters and two separate key remappings
over the same shape, one per variance side. With a single remapping every variant above passes, which
is why thirteen existing cases and a 498-project corpus all missed the regression.
saveStacks recorded the length of each checker stack and restoreStacks re-sliced whatever slice was
current. That holds for a stack only ever appended to and truncated, which is eleven of the fourteen.
Three are replaced wholesale by a caller that puts the original back only on a normal return:
checkExpressionCachedEx swaps in a nil flow-loop stack, getVariancesWorker does the same to the
variance stack, and checkSourceFile clears the renamed-binding-elements stack per file.

An unwind that crosses one of those leaves the replacement in place, so restoring by length re-slices
the wrong slice. Every getter body with a return goes through checkExpressionCachedEx, so a getter
forced from inside a loop back-edge -- where the outer flow-loop stack is non-empty -- reached
nil[:N]. That is a runtime panic, raised inside the deferred recover, so it is not the sentinel and
every outer recover re-panics it: the compiler dies on input it should merely report on.

Hold those three as slice headers instead, which is what the flow type cache in the same struct
already does. The other eleven keep their lengths, and the tail-clearing that goes with them.
Its header still said "This case is NOT fixed" and "The recursive one does not resolve", which was
true of an earlier draft and has not been true since the lookup started completing itself against the
bases still to be inherited. The `any` printed against `parent` in the .types baseline is the printer
eliding a recursive reference, not an unresolved member, so nothing in the baseline contradicted the
stale prose and it survived.

Add the assertions that would have caught it: the name three levels down is a string, a number
annotation on it is an error, and a key the shape does not declare is absent at depth. All three are
permitted on `any`, so a collapse turns them into unused-directive errors rather than passing quietly.
Copilot AI balanced review requested due to automatic review settings September 4, 2026 16:36
@github-project-automation github-project-automation Bot moved this to Not started in PR Backlog Sep 4, 2026
@typescript-automation typescript-automation Bot added For Backlog Bug PRs that fix a backlog bug labels Sep 4, 2026

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 was unable to review this pull request because the user who requested the review has reached their quota limit.

Comments and test prose only. The compiler diff here is comments alone, and no behaviour changes.

The explanations had grown into restating themselves, with asides that do not earn their line and
constructions that name what a sentence is doing instead of saying it. Cut those back to what a reader
of this code needs. The conformance case headers had the same problem, several of them explaining the
type system rather than the case at hand.

Baselines move only where a comment shifted a line number.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

For Backlog Bug PRs that fix a backlog bug

Projects

Status: Not started

Development

Successfully merging this pull request may close these issues.

Ghost error in a circular situation Confusing missing property error in a circular situation

2 participants