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.

Problem

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

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

Issue #62180 has the same cause. Its symptom is TS2741: Property 'out' is missing on an inherited member. Both are fixed here. Fixing either one alone makes the other worse.

Cause

Before it walks the base types, resolveObjectTypeMembers publishes the table of the type's own declared members as a recursion guard. Any lookup inside that window sees the inherited members as missing. Two defects come from this, and they need opposite fixes.

Inference. When an inferred candidate is checked against its constraint, the comparison walks every property of the source. An un-annotated getter gets its type from its body, so walking it re-enters a declaration that is still being resolved. The circularity is an artifact of the comparison, but it still gets reported and cached.

Publication. A member lookup that runs inside the window doesn't find the inherited members, and the caller treats them as absent.

Change

When compareProvisionally is entered it records the current depth of typeResolutions. If pushTypeResolution then finds a cycle whose start is below that mark, it throws a private sentinel. The site that forced the member, tryGetTypeOfMember, catches it, restores the checker stacks, and reports the member as unanswerable.

The unwind happens before anything is computed from the circular value, so there is no diagnostic to suppress, no placeholder type, no cache write to journal, and nothing to retract. An earlier draft did all four and came to 513 lines.

The unwind has two consequences. Each has a test that fails without it.

  • A comparison that skipped an unanswerable member can accept a candidate but can't reject one, and a success isn't written to the relation cache, since that cache outlives the state the skip depended on.
  • If the unwind crosses the publication window, the type in flight would be left with MembersResolved set and only its own members in the table. So resolveStructuredTypeMembers clears the flags on the way out.

For the publication defect, resolveObjectTypeMembers pushes the base types it is about to inherit from before it starts. A lookup that misses inside the window then checks those bases and returns the member the table is about to get anyway. If a lookup re-enters through the type in flight, its record is already marked as being consulted, so it falls back to the published table, which is the same view the inheritance loop itself has.

Gating on provisionalDepth

The right answer for a miss in the window depends on the caller. Inside a provisional comparison, completing the lookup against the bases would force the type the comparison is asking about. Outside one, the miss just means the lookup ran too early, and completing it is correct. So the completed lookup only runs when provisionalDepth == 0. Inside a provisional region a narrower rule applies instead. A getUnmatchedProperty miss is treated as "not resolved yet" rather than "absent", but only when some base declares that name. That check, mayInheritProperty, reads declaration tables and doesn't force any member type. It isn't completely free of side effects, 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.

The table below is measured on a corpus of probe files that reduce the shapes this affects. The count is of errors that ask for an annotation the user can't 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 on its own makes inference slightly worse. Without the gate it brings back the collapse on plain mutual recursion (get posts() { return array(post); }) in three files, because completing the lookup forces posts in the middle of inference. Dropping the suppression instead adds fifteen errors.

Tests

There are fifteen conformance cases with baselines. They cover the recursive type and the different ways it gets written, 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 the shape in #62180. The fourslash fixture is the code from #62180 verbatim.

Five of them behave the same as main and exist to pin that: overload resolution across an unresolved accessor, in both generic and plain signatures; a skipped accessor's constraint error still being reported; a property that really is absent still being reported as absent; and the same on the speculative path. Each of these caught a real bug during development.

The flow-loop case, recursiveTypeThroughObjectLiteralGetterFlowLoop.ts, covers the stack rollback. Its getter body runs a loop and assigns to a union-typed variable, which forces the inner getter from a loop back-edge. Since checkExpressionCachedEx swaps in a nil flowLoopStack and only restores the real one on normal return, an unwind that crosses it has to put the saved stack back rather than re-slice the replacement. If it doesn't, the checker crashes.

The mutual case, recursiveTypeThroughObjectLiteralGetterMutual.ts, is the only test in the suite that reaches the gate. To get there it needs two schemas that name each other through getters, plus two key remappings over the same shape, one on each variance side, each keyed on a different member of the internals. With only one remapping every candidate in the table passes and the gate never matters. Thirteen hand-written cases and the whole corpus missed this. The case is reduced from a real failure.

Every case that asserts a resolved type also reads a key that doesn't exist, behind a @ts-expect-error. On main that directive is reported as unused.

There are five fourslash cases. Two are #62181, one of which checks that the diagnostics are the same whether or not a hover was requested first. The other three check postponed-constraint reporting across different file open orders. One of them never opens the file that has the error.

Cost

All 63 packages under go test ./internal/... pass. No existing reference baseline changes; the only baseline changes are the cases added here.

Code that doesn't hit the pattern is unaffected. The --extendedDiagnostics counters below are the same on every 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 the pattern:

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

The increase is mostly types that collapsed to any on main and now resolve.

I couldn't measure a wall-clock difference. Interleaved runs on the inference-heavy projects give an effect that changes sign from run to run, and control runs of the same binary against itself move by as much as the measured effect.

I compiled 498 projects with both compilers and compared the full diagnostic text. Each was compiled twice on main first, because three or four of them are nondeterministic there.

identical diagnostics 496
differ 1
excluded as nondeterministic on main 4

The one that differs is the probe corpus. The declarations that asked for an annotation on main now infer.

Effect on Zod

Zod replaces its real constraint with a loose structural one at 296 sites, because with the real constraint inference through a getter fails and the schema collapses to any.

This commit removes all 296 and puts the real constraint back at every site. On main that version of the library reports 189 errors, and 167 of them are declarations that need an annotation, getters that need a return type, and the constraint failures that follow once those collapse. With this change all 167 are gone. The remaining 22 are unused symbols and a missing @types/node in that checkout. The recursive schemas in that library cover recursion through a union, a union of one, a discriminated union, mutual recursion, recursive tuples, cyclic data and z.lazy.

Scope

The unwind can only be reached from a provisional comparison. The 498-project comparison and the unchanged counters above agree with that.

One shape isn't fixed, and there is a test for it. When the parameter is a mapped type over the inferred type, so that inference goes through a reverse mapped type, the declaration doesn't resolve on main or with this change. The test, recursiveTypeThroughObjectLiteralGetterReverseMapped.ts, asserts that both report the same errors. A provisional comparison explores further than an ordinary check does, and that must not show up as an extra diagnostic.

Other than that I don't know of a remaining failure. That is measured against the conformance and fourslash suites, the 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