Repository navigation
Resolve class and interface members through shared layouts - #64765
Jake Bailey (jakebailey) wants to merge 25 commits into
Conversation
Every instantiation of a generic class or interface eagerly built a full symbol table and instantiated every own and inherited member, even though most code only ever looks at a handful of them. On large projects this dominates checker memory: VS Code creates millions of instantiated symbols that are never used. The names, order, and flags of a class or interface's members do not depend on its type arguments, so compute them once per target as a layout that records where each member comes from, and share it across instantiations. Each instantiation then only holds a slot per member, filled on first access. There is a single path for resolving a member; full resolution is just resolving every slot, so property order and semantics are unchanged. Callers that only need the count, names, or flags of properties use the layout directly so they don't force resolution.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
There was a problem hiding this comment.
🟡 Changes recommended
Non-layout base lookup can return an incorrect inherited symbol, and the new resolution path lacks regression coverage.
1 open finding
What changed in this PR
Introduces shared member layouts to lazily resolve class and interface members, reducing checker memory and symbol creation.
Changes:
- Adds shared layouts and on-demand member slots.
- Uses unresolved property shapes for count/flag checks.
- Updates consumers to use lazy-aware accessors.
| File | Description |
|---|---|
tsc/internal/checker/checker.go |
Implements layout construction and lazy resolution. |
tsc/internal/checker/types.go |
Adds lazy member state and accessors. |
tsc/internal/checker/relater.go |
Avoids unnecessary property resolution. |
tsc/internal/checker/services.go |
Uses lazy-aware member enumeration. |
tsc/internal/checker/nodebuilderimpl.go |
Materializes members when serializing types. |
tsc/internal/checker/nodebuilder_hover.go |
Materializes properties for hover output. |
🧠 Review effort: Balanced
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
Once a statement exceeds the instantiation count limit, every further instantiation yields the error type, including the base types of a class or interface instance whose members are resolved afterward. The shared layout of that class or interface still expects to find inherited members in those bases, so resolving such a member crashed. Without a shared layout, an error base type simply contributes no members, matching the behavior before shared layouts.
This comment was marked as outdated.
This comment was marked as outdated.
Building a member layout allocated a fresh entry slice, name map, and rank map every time, which made it one of the larger sources of allocation in the checker: about 10% of all bytes allocated when checking VS Code. Layouts are built often enough, and the scratch storage is discarded immediately, so keep it on the checker and reuse it instead. On VS Code, bytes allocated by layout building drop from 503MB to 86MB, total bytes allocated from 4.97GB to 4.41GB, and allocation count by about 2%.
|
TypeScript Bot (@typescript-bot) test it |
|
Hey Jake Bailey (@jakebailey), the results of running the DT tests are ready. Everything looks the same! |
|
Jake Bailey (@jakebailey) Here they are:
tscComparison Report - baseline..pr
System info unknown
Hosts
Scenarios
Developer Information: |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Jake Bailey (@jakebailey) Here are the results of running the user tests with tsc comparing Everything looks good! |
|
Jake Bailey (@jakebailey) Here are the results of running the top 400 repos with tsc comparing Everything looks good! |
Review feedback was that the member layout code is hard to read.
Member origins were encoded with negative sentinels in the base and
slot fields, the sort ranks were bare numbers, and callers that wanted
property names and flags without resolving members had to juggle a
pair of return values whose meaning depended on each other.
Give member origins an explicit kind, name the sort ranks, and replace
the shapes-and-lazy-members pair with getPropertyShapesOfType and
getPropertyOfTypeAt, which work the same for every type. The compact
layout representation is kept: a map index and per-member keys cost
about 4% more memory on VS Code, since every interface layout
includes all of its inherited members.
│ before │ after │
│ heap-B/op │ heap-B/op vs base │
vscode 3.539Gi ± ∞ 3.548Gi ± ∞ +0.25% (p=0.029 n=4)
mui 2.164Gi ± ∞ 2.169Gi ± ∞ +0.23% (p=0.029 n=4)
│ before │ after │
│ allocs/op │ allocs/op vs base │
vscode 36.24M ± ∞ 36.26M ± ∞ ~ (p=0.886 n=4)
mui 32.13M ± ∞ 32.18M ± ∞ ~ (p=0.686 n=4)
# Conflicts: # tsc/internal/checker/checker.go # tsc/internal/checker/nodebuilderimpl.go # tsc/internal/checker/relater.go # tsc/internal/checker/types.go
Lazily resolved members add one pointer to every structured type.
|
TypeScript Bot (@typescript-bot) perf test this faster |
|
Jake Bailey (@jakebailey) Here they are:
tscComparison Report - baseline..pr
System info unknown
Hosts
Scenarios
Developer Information: |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
A shared layout addresses inherited members by their slots in each base's shared layout. Checking only for error bases missed a base whose own base hit the instantiation limit: that base gets a smaller layout, so the derived type's slots pointed at the wrong members or past the end of the base's members.
Instantiations of a variadic tuple base can have different tuple targets, and so different member layouts. The derived interface still reuses the layout built for its first instantiation, so it loses the added tuple elements. This test captures the current, incorrect output.
Inherited member slots index into the base's layout, so a shared layout is only valid for instantiations whose bases resolve to the exact base layouts it was built from. Instantiations of a variadic tuple base produce different tuple targets with distinct layouts, so reusing the cached layout read members from the wrong slots.
An intersection base without type parameters can still reference the polymorphic this type, so its members can differ between instantiations of the derived type. Shared member layouts assumed otherwise and crash looking up a member that no longer exists.
A computed member name that reads a member of its own class or interface resolves the type's members while its late-bound members are still being bound. The layout built from that partial member table must not be reused once the late-bound members are known.
Finding inherited members by name relied on bases without type parameters having the same members in every instantiation, which doesn't hold when a base references the polymorphic this type. A layout is now shared only when each base's members are known to be fixed: the base is any or has a shared layout itself.
Late binding a member name can resolve its own type's members before the late-bound members are known, and the shared layout built then was reused for every later instantiation. Dropping it when the declared members are replaced lets the next instantiation build a layout with the complete members.
Each review of member layouts has found another way a shared layout can disagree with the members its type would otherwise have. Checking every lazily resolved class and interface type against members resolved as before layouts finds such disagreements across whole test suites and real projects, rather than one case at a time. The check is enabled for now so that test runs over user and top repositories can report mismatches.
|
TypeScript Bot (@typescript-bot) user test this |
|
Hey Jake Bailey (@jakebailey), this PR is in an unmergable state, so is missing a merge commit to run against; please resolve conflicts and try again. |
|
TypeScript Bot (@typescript-bot) user test this |
|
Jake Bailey (@jakebailey) Here are the results of running the user tests with tsc comparing Everything looks good! |
|
Jake Bailey (@jakebailey) Here are the results of running the top 1000 repos with tsc comparing Everything looks good! |
The user and top1000 test runs found no mismatches, so the check is no longer needed. This reverts commit cfab455.
Every type already has a pointer to its checker, so lazy members don't need their own. Instantiations only have slots for their properties, since unnamed members are never instantiated, and the eager symbol table instantiation is no longer used.
Class and interface members are always keyed by their symbols' names, so a separate key array only added a case that never happens. An assertion now guards that assumption instead.
Whether a layout could be shared was decided by a list of special
cases: any bases, error bases from the instantiation limit, variadic
tuple bases, and this-dependent intersections. Each one was found by a
crash or wrong result, and any base type not on the list made the
layout unshared. For example, @types/node declares EventTarget with an
{} base, so every DOM type above it rebuilt its layout for each
instantiation.
A layout only depends on the declared members and the members of the
bases. Keying it on the bases' layouts, or on the base types themselves
when they have no layout, covers all of those cases with one rule and
shares layouts whose bases are the same non-generic type.
Inherited members were found by resolving the base type again and indexing its current members with the slot from the layout. That's only correct if the base still has the layout this layout was built from, which held but wasn't guaranteed by anything local. Keeping the base members that were used to choose the layout makes the slots always refer to the right layout, and matches addInheritedMembers, which copies the base members as they are when the type is resolved.
The layout order must match the order getNamedMembers gives resolved members, and it was produced by a separate ranking and sort that had to be kept in sync with getNamedMembers by hand. Building the same symbol table addInheritedMembers would and passing it to getNamedMembers makes the order the same by construction.
Turn the check back on for a run over the top 1000 repos, to confirm the simplified layout sharing and ordering still match eager member resolution. It will be reverted again afterwards.
|
TypeScript Bot (@typescript-bot) test top1000 |


We currently resolve the members of an instantiated class or interface eagerly. The first time anything touches an instantiation such as
Array<string>, we instantiate every declared member, copy in every inherited member from its base types, and build a fresh symbol table and property list. This is wasteful because most instantiations only ever have a few of their members accessed. Meanwhile, the names, order, and flags of the members are identical in every instantiation; only their types differ. On large projects, these mostly unused symbols and tables are a large share of checker memory.This PR computes each class or interface's member names, order, and origins once, as a member layout that all of its instantiations share. A member's origin is one of three things: a declared member, a slot in a base type's layout, or a property of a base type looked up by name. Each instantiation holds only a slot array, and a slot is filled the first time that member is accessed. Every member goes through one code path,
getLazyMember. Looking up a member by name resolves one slot, and full resolution (Properties(),getMembers()) resolves every slot, so property order and semantics don't change. Callers that need only the count, names, or flags of properties (isWeakType,getUnmatchedProperties,isEmptyResolvedType, etc.) read the layout and don't resolve anything.Compared to main, VS Code uses 13.7% less memory and creates 47% fewer symbols; xstate uses 15.7% less memory and creates 40% fewer symbols.
Anonymous types (instantiated type literals, object literals) still resolve their members eagerly. I tried giving them shared layouts too, but nearly all of their members end up being used and they don't keep a separate symbol table today. On VS Code, that cost about 30MB more memory for almost no symbol savings.
This is the third attempt at avoiding eager member instantiation, after #64475 and #64735. Both of those keep a lazy path for individual lookups alongside the existing eager path for full resolution, so the two must agree. The feedback on them was that full resolution should simply resolve every member through a single code path. Here, the shared layout is what makes that possible: the names, order, and origins of members are known before any member is resolved, so one per-member path serves both a single lookup and full resolution.
Performance
From the perf bot (baseline is main, n=12):