Skip to content

build members of keyof mapped types lazily - #64526

Draft
Max Schwenk (maschwenk) wants to merge 12 commits into
microsoft:mainfrom
maschwenk:perf/lazy-mapped-members
Draft

Max Schwenk (maschwenk) wants to merge 12 commits into
microsoft:mainfrom
maschwenk:perf/lazy-mapped-members

Conversation

@maschwenk

@maschwenk Max Schwenk (maschwenk) commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

this is stacked on the lazy member tables PR, the first 10 commits here are that one and the last 2 are new. leaving it as a draft until that lands

{ [P in keyof T]: X } mapped types build a symbol for every property of T as soon as anything asks about one of them. in react and test code thats a lot of symbols nobody reads: Partial<Props> just to look up one prop, or vitest's expect(x), whose type extends a mapped type over ~100 chai methods for every distinct x

this gives those mapped types (T is an object type, no as clause) a lazy table:

  • a lookup creates just that member, the same way resolveMappedTypeMembers would, and resolving everything later reuses it
  • index infos get computed on their own from T's index infos
  • mapped types never have signatures, so getSignaturesOfStructuredType doesn't resolve them to find out
  • setting up the table does the same steps as resolveMappedTypeMembers up to creating members, so circularity errors show up at the same point (circularBaseTypes.ts caught this)

two places listed every property to answer a narrower question, which would resolve the mapped type anyway:

  • somePropertyReducesToNever only cares about names in more than one constituent, so one unresolved constituent gets looked up by name instead of listed. any name it shares with another constituent gets counted from that other one
  • lazy member tables no longer list the properties of an intersection base type when they're set up

vs the lazy member tables PR, typescript-benchmarking, median of 3:

heap, 4 checkers peak rss, 4 checkers check, 4 checkers heap, single check, single
mui-docs −18.7% −24.4% −21.1% −11.9% −12.4%
xstate-main −2.3% +1.2% +1.4% −2.1% −5.4%
vscode −0.1% +3.0% +3.1% −0.1% −4.0%
webpack −0.2% −0.4% −3.1% −0.2% −4.4%
Compiler +0.4% −1.2% −1.4% 0.0% +3.4%
Compiler-Unions +0.4% −1.5% −9.6% −0.1% −0.3%

outside mui-docs the check times are within noise, most of those are under a second

on our 37k-file program heap goes 15.27 → 14.50 GiB with 4 checkers (−5.0%, peak rss 19.1 → 17.7 GiB) and 10.47 → 9.91 GiB single threaded (−5.3%), check time about −5% single threaded. same diagnostics on every run everywhere

full go suite passes (also -race), lint and format are clean. added mappedTypeLazyMembers.ts with baselines generated without this change, so it pins that the output didnt change

  • associated issue (same as the lazy member tables PR)
  • up to date with main (stacked, see above)
  • tests pass (go test ./... in tsc, same thing hereby test runs)
  • hereby lint
  • hereby check:format
  • new tests

used claude code to help write this, ive reviewed it

Looking up one property of an instantiated class or interface reference
(`expect(x).toBe`, `schema.optional`, `arr.map`) resolved all of its
members: every declared member was instantiated and every inherited member
merged, although most of those symbols are never used.

The first lookup on such a reference now prepares a lazy member table
instead. Preparing does everything resolveObjectTypeMembers does except
create the member symbols, in the same order, so everything that is
resolved along the way (signatures, index infos, base types and their
members) is resolved exactly as before. It also records which declared
members instantiateSymbol would return as they are at that point, since
that depends on what has been resolved. Lookups then instantiate only the
requested member, walking the prepared base types in addInheritedMembers
order, and resolving the members in full later reuses the symbols already
handed out. If preparing leads back to the reference, it exposes the same
partial members resolveObjectTypeMembers would, and is resolved in full
from then on.

A lookup of a missing name answers the Object/Function augmentation from
the number of call and construct signatures, counted from the declared
signatures and those of the prepared base types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…bers

isWeakType, getSingleSignature, getSignaturesOfStructuredType,
getIndexInfosOfStructuredType, isEmptyObjectType, isFunctionObjectType and
isStringIndexSignatureOnlyType resolved all members of an instantiated
class or interface reference just to count its properties, signatures or
index infos, or to see whether its properties are optional.

For a reference with a lazy member table these are now answered from the
table. Signatures are counted from the instantiated declared signatures and
those of the prepared base types. Whether there are properties, and whether
all of them are optional, follows from the declared members (which have the
same flags as their instantiations) and the properties of the base types,
merged as in addInheritedMembers. Index infos are merged from the
instantiated declared index infos and those of the base types, as in
resolveObjectTypeMembers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolving the members of a lazily prepared reference in full stored every
instantiated member in the table's memo, which is discarded right after,
and checked each member against the list of members that instantiate to
themselves with a linear scan. The list is now sorted and binary searched,
and members are only memoized when looked up individually.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Property and index info lookups are hot, and nearly always see types whose
members are resolved. They now check that inline before calling into the
lazy member table code, which on material-ui's docs project took 2-3% of
check time on its own. The lazy part of getPropertyOfTypeEx moves to
getPropertyOfObjectTypeLazily.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each shape check (isWeakType, getSingleSignature,
isStringIndexSignatureOnlyType, isFunctionObjectType, getPropertyOfTypeEx)
had a second, lazy version of its condition. Now the lazy table stores the
reference's full signatures and index infos, inherited through a helper
resolveObjectTypeMembers shares, and getSignaturesOfStructuredType and
getIndexInfosOfStructuredType answer from it. The checks read through those
accessors and getMemberOfStructuredType, hasPropertiesOfStructuredType and
everyPropertyOfStructuredType, so each condition is written once.

The separate shape summary goes away, as do the hooks in
getPropertyOfObjectType and isEmptyObjectType, which saved no memory, and
the code moves into checker.go.

This also removes a crash path: isWeakType read the table's shape, called
getIndexInfosOfStructuredType, and read the shape again, which would find
no table if that call had resolved the type in full.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
resolveObjectTypeMembers no longer exposes a type's declared members while
its base types resolve, so the lazy table doesn't need to either. A table is
now either being prepared, during which the type resolves its members as
usual, or ready; the materialized state, the count of inherited base types
and the partial lookup paths go away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Inherited properties are looked up with getPropertyOfTypeEx instead of a
separate helper, lookups are no longer memoized (the memo cost more memory
than it saved, with no change in check time), and a few one-use helpers are
inlined.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One test covering what the two did that the existing suite doesn't: member
lookups with redeclared and private members, base types whose shape depends
on the instantiation (weak types, bind, string index signatures, merged
signatures), an interface extending a type parameter, a recursive base type
and function narrowing. Baselines are generated on unmodified main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A { [P in keyof T]: X } mapped type over an object type, with no as clause,
gets a lazy table instead of resolving all of its members: a lookup creates
only the requested member, the way resolveMappedTypeMembers would, and full
resolution later reuses it. Index infos are computed on their own, and mapped
types answer that they have no signatures without resolving. Setting up the
table runs the same steps as resolveMappedTypeMembers up to creating members,
so circularity errors are reported at the same point.

somePropertyReducesToNever looks up names in one unresolved constituent
instead of listing its properties, since a name that occurs in more than one
constituent occurs in another one too. Lazy member tables no longer list the
properties of intersection base types when they're set up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Member lookups on Partial, Required and -readonly mapped types, index
signatures from the modifiers type, an assertion chain whose base type is a
mapped type intersected with a call signature, intersections with mapped
types that do and don't reduce to never, and circular mapped types.
Baselines are generated without the previous commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@typescript-automation typescript-automation Bot added the For Uncommitted Bug PR for untriaged, rejected, closed or missing bug label Sep 29, 2026

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

None yet

Development

Successfully merging this pull request may close these issues.

1 participant