Infer recursive types through self-referential object literals - #64248
Open
Colin McDonnell (colinhacks) wants to merge 13 commits into
Open
Infer recursive types through self-referential object literals#64248Colin McDonnell (colinhacks) wants to merge 13 commits into
Colin McDonnell (colinhacks) wants to merge 13 commits into
Conversation
Record pre-fix compiler and diagnostic baselines for recursive schema constraints, getter ordering, wrappers, constructor and method calls, declaration emission, and cross-file language-server requests. Include rejected-overload and invalid-member cases to guard constraint enforcement.
Allow single-signature calls with an unambiguous inference candidate to finish inference without forcing unresolved object-literal getters. Check all inferred type argument constraints in the existing deferred node pass, using the final selected signature. Keep overloaded and competing-candidate inference on their existing paths. Resolve generic interface members before substituting computed base type arguments so recursive lookups see inherited members. Avoid unnecessary getter evaluation for unknown index signatures and update signature flag bindings and baselines.
Reduce the recursive schema failure to mapped shapes and standard-schema metadata. Force the mapped getter before querying output, since querying output first can mask the circularity. Check deep recursive output and invalid accesses with both exact optional property settings. Cover covariant, contravariant, invariant, conditional, and polymorphic this references before changing deferred reference normalization. Preserve the pre-fix diagnostics and inferred types in the baselines.
Deferred reference normalization resolves every source type argument before comparing references. For recursive schema metadata, this can evaluate the getter that the enclosing call is still inferring. For assignability between references to the same generic target, exact covariance and all-any/unknown target arguments prove the relation without evaluating source arguments. Exclude unreliable or unmeasurable variance, tuple normalization, and additional target this arguments. Keep generic constraints and overload resolution unchanged. The pinned Zod corpus drops from 58 to 47 diagnostics; 22 are identical environment and unused-declaration diagnostics. The remaining 25 still need work. Do not broaden the mapped-origin inference walk here: that alternative introduced a verified regression in recursive tuple inference.
Add derived metadata and query-order regressions, preserving the old circularity diagnostics and inferred types in their baselines. Check distinct substitutions, indexed keys, member incompatibilities, constrained target parameters, outer scopes, and explicit circular variable annotations as negative controls. Exercise recursive inheritance independently of the probe directory's global declaration collisions. Cover array and tuple constructors, nullable elements, readonly arrays, aliases, and nested-value precision.
Compare deferred argument expressions under fixed captured mappers before normalizing their values. Recognize matching conditional environments and indexed accesses, and prove compatible generic declarations without inspecting their recursive arguments. Keep constraint-bearing targets out of the cross-declaration identity shortcut. Preserve eagerly checked arguments and their syntax for later generic substitution. This allows guarded arrays and fixed tuples to be constructed before resolving dependent arguments, without suppressing circular variable-annotation errors. Exclude spread/rest tuple syntax whose normalized target may have a different argument shape. On the pinned workaround-removal corpus, diagnostics fall from 47 to 22. The remaining errors are unchanged setup and unused declarations; the clean configuration reports none. Independent heritage cases retain the same recursive element type through 64 successive post-diagnostic API queries. Nonrecursive comparison controls preserve their diagnostics.
Run generic-declaration proofs through the current relater. Keep recursive assumptions and comparison budgets with the enclosing relation, and propagate Ternary results instead of promoting a provisional result to success. Cover recursive success and failure, repeated negative comparisons, and concrete specializations of unsuccessful generic proofs. Extend the variance, inherited-member, index-signature, and argument-inference controls to exercise computed and deferred references.
Run validation after syntax-specific deferred checks, including self-closing JSX and instanceof expressions. Preserve JSX element validation and avoid rechecking successful calls as untyped calls. Keep the obligation on the selected invocation instead of propagating its flag through signature cloning and instantiation. Add positive and negative JSX, tagged-template, rest-argument, and instanceof coverage.
Base eligibility on accessor presence in the construction-time candidate graph, not getter or deferred-reference resolution caches. Eligible candidates retain their actual inferred type, even when a constraint fails. This keeps recovery consistent across earlier getter reads, hovers, diagnostics, and signature-help requests. Make cross-file diagnostic ordering explicit. Verify consumer-only requests, first-response types, recursive precision, and constraint errors before any aggregate baseline request can warm another file. Add cold/warm controls for both direct and deferred-wrapper inputs.
The single-candidate gate on deferral is load-bearing. Removing it makes the recursive getter resolve through an overloaded call, but when the constraint is the only thing distinguishing the signatures it selects the wrong overload and reports an error from a candidate that should have been rejected - the failure RyanCavanaugh identified in the original PR. The existing rejected-overload tests disambiguate by a string-literal argument, so the constraint is never the deciding factor and they do not catch this.
… survives a spread getSpreadSymbol dropped the accessor flag and forced the getter eagerly, so a recursive getter reached through an object spread resolved before its declaration had a type. The spread copy now resolves through the origin accessor on demand and is shared across every check of the same literal.
A property whose initializer is a call or new expression, and whose callback names a declaration still being typed, is now typed on demand through its own symbol instead of eagerly in checkObjectLiteral. The look-ahead treats it as a getter, widening leaves it alone, and it gets no contextual type from itself. This is the lazy(() => array(Self)) shape.
…declaration A function-valued property whose un-annotated body names a declaration under resolution is treated like a getter for the constraint check only. Its contextual typing is untouched; deferring the typing itself breaks it.
Colin McDonnell (colinhacks)
force-pushed
the
combined
branch
from
September 11, 2026 18:22
6b00669 to
15bb626
Compare
Colin McDonnell (colinhacks)
added a commit
to colinhacks/frizz
that referenced
this pull request
Sep 11, 2026
…rough the `gh` fallback Two sources of low-value "CI awaiting approval" wakes, both seen on the maintainer's board 2026-09-11 against microsoft/TypeScript#64248: 1. The checks stamp keys a verdict on the head so that red-again-on-a-new- commit speaks. A gated reading is not a verdict — the worker's one move is to ask for the approval, on any commit — yet a force-push while the gate was shut re-announced "WAITING FOR APPROVAL" for the new SHA three minutes after saying it for the old one. Gated-after-gated is now quiet whatever the head; gated after a real verdict still speaks. 2. `gh run list --json conclusion` prints `action_required` where the batched GraphQL poll prints `ACTION_REQUIRED`, and both readers compared the raw string against the upper-case word. Every tick the GraphQL batch failed (`fetch failed` on one request sends every armed ref down the fallback at once) read a gated fork PR as "✅ CI PASSED — 1 check green", and the next poll flipped it back: two wakes per blip on every gated watcher, one of them false. #64172 collected four such pairs; typeorm/typeorm#12842 and Unitech/pm2#6151 flipped together at six instants over three days. Pinned in github-watch.test.ts (verbatim `gh run list` output beside the verbatim one-entry rollup) and awaiting-watch.e2e.test.ts (a force-push while gated; a tick served by the fallback). All four fail with the fix reverted.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This is Jake Bailey (@jakebailey)'s
fix-recursive-getter-resolutionbranch, with a few commits of mine on top of it. Supersedes #64172.The goal is to make this work:
Right now
nodeimplicitly has typeany(TS7022) and so does the getter (TS7023). The getter is there because it's the one kind of property TypeScript types lazily, and that's enough for a plain object literal, but as soon asobject()has a constraint on its type parameter the inference falls apart. Zod 4 gets around this by not putting a constraint onz.object()at all, soz.object({ a: "not a schema" })is a valid schema today. I'd like to stop doing that.What Jake's branch does: the constraint check normally happens the moment inference produces a type argument, and that check is what forces the getter and starts the cycle. Now the inferred type is looked at first, and if there's an object literal with a getter in it, the constraint check is deferred until a signature has actually been picked. This only happens when there's a single candidate signature, so a rejected overload can't produce an error. With Jake's branch alone, Zod typechecks with all of its workarounds deleted (zero errors), and if I plant a wrong value a few levels deep in a recursive schema I get an error instead of
any.My commits on top:
children: lazy(() => array(node)), which is the shape Zod actually documents. A property initialized by a call whose callback refers back to the thing being resolved is now typed lazily via its own symbol, the same way a getter is.children: () => array(node)(this is what Drizzle and TypeORM do) and for a getter that arrives via spread. These are smaller.None of these change existing baselines. More detail in the commit messages.
Two things still don't work.
object({ ... }, (code) => {}), i.e. an unannotated callback next to the literal, still ends upanybecause the unannotated parameter makes the call go through two inference passes and the first one forces the getter. Adding a type to the parameter fixes it. I looked at this one and it needs changes in three separate places, so I left it alone. The other isconst self = lazy(() => self)with nothing in between, which would need a deferred type for the result of an expression. Different change.There are fixtures for all of these (working and not) at https://github.com/colinhacks/recursive-inference.
Fixes #62181
Fixes #62180
Refs #64192