From 016a2f54ab5c967b2db0e7af6aef4dc12d59d010 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:00:39 -0700 Subject: [PATCH 1/5] Pool the duplicate-name map in object literal grammar checks checkGrammarObjectLiteralExpression allocated a fresh map for every object literal it checked, even though most literals have zero or one property and so can never contain duplicate names, and the map is discarded as soon as the check returns. Only use a map when duplicates are possible (two or more properties outside of destructuring), and take it from a small per-checker free list so that repeated checks reuse the same backing storage. Nested object literals checked while computing property names take their own map from the list, so reentrancy is safe. Unusually large maps are not retained, since Go maps never shrink. Co-authored-by: Steven Chong <25894545+teamchong@users.noreply.github.com> --- tsc/internal/checker/checker.go | 1 + tsc/internal/checker/grammarchecks.go | 28 ++++++++++++++++++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index d9cb44e00a903..58a6188a64fb7 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -697,6 +697,7 @@ type Checker struct { layoutMemberInfos map[string]MemberInfo freeTypeSets *temporaryTypeSet freePropertyCounts *temporaryPropertyCounts + freeObjectLiteralSeenMaps []map[string]DeclarationMeaning mergedSymbols map[*ast.Symbol]*ast.Symbol mergedExportsChecked collections.Set[*ast.Symbol] factory ast.NodeFactory diff --git a/tsc/internal/checker/grammarchecks.go b/tsc/internal/checker/grammarchecks.go index 93bed4b355296..e62894800632a 100644 --- a/tsc/internal/checker/grammarchecks.go +++ b/tsc/internal/checker/grammarchecks.go @@ -995,13 +995,35 @@ func (c *Checker) checkGrammarForInvalidExclamationToken(postfixToken *ast.Token return postfixToken != nil && postfixToken.Kind == ast.KindExclamationToken && c.grammarErrorOnNode(postfixToken, message) } -func (c *Checker) checkGrammarObjectLiteralExpression(node *ast.ObjectLiteralExpression, inDestructuring bool) bool { - seen := make(map[string]DeclarationMeaning) +func (c *Checker) getObjectLiteralSeenMap() map[string]DeclarationMeaning { + if n := len(c.freeObjectLiteralSeenMaps); n > 0 { + seen := c.freeObjectLiteralSeenMaps[n-1] + c.freeObjectLiteralSeenMaps = c.freeObjectLiteralSeenMaps[:n-1] + return seen + } + return make(map[string]DeclarationMeaning) +} +func (c *Checker) releaseObjectLiteralSeenMap(seen map[string]DeclarationMeaning) { + // Maps never shrink, so do not retain ones grown by unusually large literals. + if len(seen) > 1024 { + return + } + clear(seen) + c.freeObjectLiteralSeenMaps = append(c.freeObjectLiteralSeenMaps, seen) +} + +func (c *Checker) checkGrammarObjectLiteralExpression(node *ast.ObjectLiteralExpression, inDestructuring bool) bool { var properties []*ast.Node if node.Properties != nil { properties = node.Properties.Nodes } + // Duplicate names are only possible with at least two properties. + var seen map[string]DeclarationMeaning + if !inDestructuring && len(properties) > 1 { + seen = c.getObjectLiteralSeenMap() + defer c.releaseObjectLiteralSeenMap(seen) + } for _, prop := range properties { if prop.Kind == ast.KindSpreadAssignment { spreadAssignment := prop.AsSpreadAssignment() @@ -1107,7 +1129,7 @@ func (c *Checker) checkGrammarObjectLiteralExpression(node *ast.ObjectLiteralExp if !inDestructuring { effectiveName, ok := c.getEffectivePropertyNameForPropertyNameNode(name) - if !ok { + if !ok || seen == nil { continue } From 6ff79090fb7a8123e1d85974ae73e6e1f35e9110 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:01:43 -0700 Subject: [PATCH 2/5] Reuse contextual type between const-context and widening checks checkExpressionForMutableLocation first asks isConstContext, which for literal-like expressions computes the expression's contextual type to see whether it is a const type parameter. When the answer is no, it then immediately computed the very same contextual type again to widen the literal. getContextualType walks up the tree and may consult resolved signatures, so doing it twice for every literal in a mutable location is wasted work. Let an internal variant of isConstContext hand back the contextual type it computed when that type alone decided the result, and use it for the widening step instead of recomputing it. When the answer came from an enclosing node instead, the type is recomputed as before. Co-authored-by: Steven Chong <25894545+teamchong@users.noreply.github.com> --- tsc/internal/checker/checker.go | 37 ++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index 58a6188a64fb7..a1ea80d935099 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -13876,12 +13876,31 @@ func (c *Checker) hasDefaultValue(node *ast.Node) bool { } func (c *Checker) isConstContext(node *ast.Node) bool { + isConst, _, _ := c.isConstContextWorker(node) + return isConst +} + +// isConstContextWorker additionally returns the contextual type of node when it was computed in the process and +// determined the (false) result, so that callers can reuse it. +func (c *Checker) isConstContextWorker(node *ast.Node) (isConst bool, contextualType *Type, hasContextualType bool) { parent := node.Parent - return ast.IsConstAssertion(parent) || - c.isInlineImportAttributes(node) || - c.isValidConstAssertionArgument(node) && c.isConstTypeVariable(c.getContextualType(node, ContextFlagsNone), 0) || - (ast.IsParenthesizedExpression(parent) || ast.IsArrayLiteralExpression(parent) || ast.IsSpreadElement(parent)) && c.isConstContext(parent) || - (ast.IsPropertyAssignment(parent) || ast.IsShorthandPropertyAssignment(parent) || ast.IsTemplateSpan(parent)) && c.isConstContext(parent.Parent) + if ast.IsConstAssertion(parent) || c.isInlineImportAttributes(node) { + return true, nil, false + } + if c.isValidConstAssertionArgument(node) { + contextualType = c.getContextualType(node, ContextFlagsNone) + if c.isConstTypeVariable(contextualType, 0) { + return true, nil, false + } + hasContextualType = true + } + switch { + case ast.IsParenthesizedExpression(parent) || ast.IsArrayLiteralExpression(parent) || ast.IsSpreadElement(parent): + return c.isConstContext(parent), nil, false + case ast.IsPropertyAssignment(parent) || ast.IsShorthandPropertyAssignment(parent) || ast.IsTemplateSpan(parent): + return c.isConstContext(parent.Parent), nil, false + } + return false, contextualType, hasContextualType } func (c *Checker) isInlineImportAttributes(node *ast.Node) bool { @@ -14157,13 +14176,17 @@ func (c *Checker) checkObjectLiteralMethod(node *ast.Node, checkMode CheckMode) func (c *Checker) checkExpressionForMutableLocation(node *ast.Node, checkMode CheckMode) *Type { t := c.checkExpressionEx(node, checkMode) + isConst, contextualType, hasContextualType := c.isConstContextWorker(node) switch { - case c.isConstContext(node): + case isConst: return c.getRegularTypeOfLiteralType(t) case isTypeAssertion(node): return t default: - return c.getWidenedLiteralLikeTypeForContextualType(t, c.instantiateContextualType(c.getContextualType(node, ContextFlagsNone), node, ContextFlagsNone)) + if !hasContextualType { + contextualType = c.getContextualType(node, ContextFlagsNone) + } + return c.getWidenedLiteralLikeTypeForContextualType(t, c.instantiateContextualType(contextualType, node, ContextFlagsNone)) } } From 4eeca5f1c0fd893f9c93a61538eb0f7a840f454f Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:05:01 -0700 Subject: [PATCH 3/5] Avoid unneeded symbol tables when checking object literals checkObjectLiteral eagerly allocated two symbol tables per literal: one for the properties of the literal type, and, under strictNullChecks, one collecting every property to detect properties that a later spread always overwrites. The latter is only ever consulted when the literal contains a spread, which most literals do not. Spreads also flush the current properties into the spread type and replace the table with a fresh one that frequently ends up unused, for instance when the spread is the last element. Allocate the overwrite-tracking table only when the literal contains a spread, and create the properties table on first use. Also skip mapType when remapping the final spread type if it is not a union, since there is only the type itself to inspect. Co-authored-by: Steven Chong <25894545+teamchong@users.noreply.github.com> --- tsc/internal/checker/checker.go | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index a1ea80d935099..f62ad9a119e71 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -13409,11 +13409,10 @@ func (c *Checker) checkObjectLiteral(node *ast.Node, checkMode CheckMode) *Type inDestructuringPattern := ast.IsAssignmentTarget(node) // Grammar checking c.checkGrammarObjectLiteralExpression(node.AsObjectLiteralExpression(), inDestructuringPattern) + // allPropertiesTable is only needed to check properties overwritten by spreads. var allPropertiesTable ast.SymbolTable - if c.strictNullChecks { - allPropertiesTable = ast.NewSymbolTable() - } - propertiesTable := ast.NewSymbolTable() + // propertiesTable is created on first use, as spreads often leave it unused. + var propertiesTable ast.SymbolTable var propertiesArray []*ast.Symbol spread := c.emptyObjectType c.pushCachedContextualType(node) @@ -13438,6 +13437,9 @@ func (c *Checker) checkObjectLiteral(node *ast.Node, checkMode CheckMode) *Type // As otherwise they may not be checked until exports for the type at this position are retrieved, // which may never occur. for _, elem := range node.Properties() { + if elem.Kind == ast.KindSpreadAssignment && c.strictNullChecks && allPropertiesTable == nil { + allPropertiesTable = ast.NewSymbolTable() + } if elem.Name() != nil && ast.IsComputedPropertyName(elem.Name()) { c.checkComputedPropertyName(elem.Name()) } @@ -13455,6 +13457,9 @@ func (c *Checker) checkObjectLiteral(node *ast.Node, checkMode CheckMode) *Type if hasComputedSymbolProperty { indexInfos = append(indexInfos, c.getObjectLiteralIndexInfo(isReadonly, propertiesArray[offset:], c.esSymbolType)) } + if propertiesTable == nil { + propertiesTable = ast.NewSymbolTable() + } result := c.newAnonymousType(node.Symbol(), propertiesTable, nil, nil, indexInfos) result.objectFlags |= objectFlags | ObjectFlagsObjectLiteral | ObjectFlagsContainsObjectOrArrayLiteral if contextualType == nil && ast.IsInJSFile(node) && !ast.IsInJsonFile(node) { @@ -13535,7 +13540,7 @@ func (c *Checker) checkObjectLiteral(node *ast.Node, checkMode CheckMode) *Type if len(propertiesArray) > 0 { spread = c.getSpreadType(spread, createObjectLiteralType(), node.Symbol(), objectFlags, inConstContext) propertiesArray = nil - propertiesTable = ast.NewSymbolTable() + propertiesTable = nil hasComputedStringProperty = false hasComputedNumberProperty = false hasComputedSymbolProperty = false @@ -13579,6 +13584,9 @@ func (c *Checker) checkObjectLiteral(node *ast.Node, checkMode CheckMode) *Type } } } else { + if propertiesTable == nil { + propertiesTable = ast.NewSymbolTable() + } propertiesTable.Set(member.Name(), member) } propertiesArray = append(propertiesArray, member) @@ -13591,11 +13599,17 @@ func (c *Checker) checkObjectLiteral(node *ast.Node, checkMode CheckMode) *Type if len(propertiesArray) > 0 { spread = c.getSpreadType(spread, createObjectLiteralType(), node.Symbol(), objectFlags, inConstContext) propertiesArray = nil - propertiesTable = ast.NewSymbolTable() + propertiesTable = nil hasComputedStringProperty = false hasComputedNumberProperty = false } // remap the raw emptyObjectType fed in at the top into a fresh empty object literal type, unique to this use site + if spread.flags&TypeFlagsUnion == 0 { + if spread == c.emptyObjectType { + return createObjectLiteralType() + } + return spread + } return c.mapType(spread, func(t *Type) *Type { if t == c.emptyObjectType { return createObjectLiteralType() From f899e04a4019c817df271ad14eba1f02cc03c033 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:08:28 -0700 Subject: [PATCH 4/5] Reuse candidate lists across call resolutions Every call resolution allocated a fresh slice for its reordered candidate signatures, even though the list is only used while that resolution is in progress (unless signature help asks for it). Call resolution is one of the hottest paths in the checker, and most calls have a single candidate, for which the general reordering loop with its symbol lookups and slice insertions is unnecessary. Keep a small per-checker free list of candidate slices: a resolution takes one, and gives it back when it returns. Nested resolutions started while checking arguments take their own slice from the list, so they never share storage with an outer resolution in progress. Lists handed out through candidatesOutArray are never reused. Zero and one signatures skip the reordering logic entirely, as there is nothing to reorder. Co-authored-by: Steven Chong <25894545+teamchong@users.noreply.github.com> --- tsc/internal/checker/checker.go | 41 ++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index f62ad9a119e71..b598331d3232a 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -698,6 +698,7 @@ type Checker struct { freeTypeSets *temporaryTypeSet freePropertyCounts *temporaryPropertyCounts freeObjectLiteralSeenMaps []map[string]DeclarationMeaning + freeCandidateLists [][]*Signature mergedSymbols map[*ast.Symbol]*ast.Symbol mergedExportsChecked collections.Set[*ast.Symbol] factory ast.NodeFactory @@ -9082,9 +9083,13 @@ func (c *Checker) resolveCall(node *ast.Node, signatures []*Signature, candidate c.checkSourceElements(s.typeArguments) } } - s.candidates = c.reorderCandidates(signatures, callChainFlags) if candidatesOutArray != nil { + s.candidates = c.reorderCandidates(nil, signatures, callChainFlags) *candidatesOutArray = s.candidates + } else { + // The candidate list does not outlive this call, so its storage can be reused. + s.candidates = c.reorderCandidates(c.getCandidateList(), signatures, callChainFlags) + defer c.releaseCandidateList(s.candidates) } if len(s.candidates) == 0 { @@ -9184,14 +9189,44 @@ func (c *Checker) resolveCall(node *ast.Node, signatures []*Signature, candidate return result } -func (c *Checker) reorderCandidates(signatures []*Signature, callChainFlags SignatureFlags) []*Signature { +func (c *Checker) getCandidateList() []*Signature { + if n := len(c.freeCandidateLists); n > 0 { + list := c.freeCandidateLists[n-1] + c.freeCandidateLists = c.freeCandidateLists[:n-1] + return list + } + return nil +} + +func (c *Checker) releaseCandidateList(list []*Signature) { + if cap(list) == 0 || cap(list) > 64 { + return + } + clear(list) + c.freeCandidateLists = append(c.freeCandidateLists, list[:0]) +} + +// reorderCandidates appends the reordered candidates to dst[:0], reusing its storage when possible. +func (c *Checker) reorderCandidates(dst []*Signature, signatures []*Signature, callChainFlags SignatureFlags) []*Signature { + result := dst[:0] + if len(signatures) <= 1 { + for _, signature := range signatures { + if callChainFlags != 0 { + signature = c.getOptionalCallSignature(signature, callChainFlags) + } + result = append(result, signature) + } + return result + } + if cap(result) < len(signatures) { + result = make([]*Signature, 0, len(signatures)) + } var lastParent *ast.Node var lastSymbol *ast.Symbol var index int var cutoffIndex int var spliceIndex int specializedIndex := -1 - result := make([]*Signature, 0, len(signatures)) for _, signature := range signatures { var symbol *ast.Symbol var parent *ast.Node From df81007915e3c079b59633d425d9fb9a87fb5ab7 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:40:29 -0700 Subject: [PATCH 5/5] Clear popped slots in the checker's free lists A popped entry stayed reachable from the free list's backing array until it was overwritten. If the borrowed value then grew too large to be returned to the pool, the stale slot kept it alive anyway, defeating the retention limit. --- tsc/internal/checker/checker.go | 1 + tsc/internal/checker/grammarchecks.go | 1 + 2 files changed, 2 insertions(+) diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index b598331d3232a..6c85ea4873cd2 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -9192,6 +9192,7 @@ func (c *Checker) resolveCall(node *ast.Node, signatures []*Signature, candidate func (c *Checker) getCandidateList() []*Signature { if n := len(c.freeCandidateLists); n > 0 { list := c.freeCandidateLists[n-1] + c.freeCandidateLists[n-1] = nil c.freeCandidateLists = c.freeCandidateLists[:n-1] return list } diff --git a/tsc/internal/checker/grammarchecks.go b/tsc/internal/checker/grammarchecks.go index e62894800632a..6a9ee1af3d2f7 100644 --- a/tsc/internal/checker/grammarchecks.go +++ b/tsc/internal/checker/grammarchecks.go @@ -998,6 +998,7 @@ func (c *Checker) checkGrammarForInvalidExclamationToken(postfixToken *ast.Token func (c *Checker) getObjectLiteralSeenMap() map[string]DeclarationMeaning { if n := len(c.freeObjectLiteralSeenMaps); n > 0 { seen := c.freeObjectLiteralSeenMaps[n-1] + c.freeObjectLiteralSeenMaps[n-1] = nil c.freeObjectLiteralSeenMaps = c.freeObjectLiteralSeenMaps[:n-1] return seen }