From f00d5e13bbf2e9a9e88c08e75085cd9e8ea75486 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:36:10 -0700 Subject: [PATCH 1/5] Merge pre-sorted runs when building union type sets addTypesToUnion collected the constituents of every source type into one list and then sorted the whole list with a stable sort. Most of that work is redundant: the constituents of each source union are already sorted by CompareTypes, and unions are very often built from just a few such lists (for example, a union plus `undefined`, or two unions). The full sort re-compares elements whose relative order is already known, and CompareTypes is not cheap for many kinds of types. Instead, note where each source type's sorted run begins out of order with the previous run, and merge those runs pairwise. When the input is already in order no sorting is done at all. Because CompareTypes is a total order that only reports equality for identical types, the merged result is exactly the list the stable sort produced. Co-authored-by: Steven Chong <25894545+teamchong@users.noreply.github.com> --- tsc/internal/checker/checker.go | 73 ++++++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index 086779ceb115a..f09149b844f02 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -696,6 +696,7 @@ type Checker struct { memberLayoutArena core.Arena[MemberLayout] layoutMemberInfos map[string]MemberInfo freeTypeSets *temporaryTypeSet + unionRunStarts []int freePropertyCounts *temporaryPropertyCounts mergedSymbols map[*ast.Symbol]*ast.Symbol mergedExportsChecked collections.Set[*ast.Symbol] @@ -26540,9 +26541,14 @@ func (c *Checker) addTypesToUnion(sourceTypes []*Type, types []*Type) ([]*Type, } types = append(types, t) } + // Each source type contributes an already sorted run of types (a single type or the sorted + // constituents of a union). Record where a run is out of order with its predecessor so that + // the runs can be merged instead of sorting everything from scratch. + runStarts := c.unionRunStarts[:0] var lastType *Type for _, t := range sourceTypes { if t != lastType { + start := len(types) if t.flags&TypeFlagsUnion != 0 { u := t.AsUnionType() if t.alias != nil || u.origin != nil { @@ -26554,12 +26560,17 @@ func (c *Checker) addTypesToUnion(sourceTypes []*Type, types []*Type) ([]*Type, } else { addType(t) } + if start > 0 && len(types) > start && CompareTypes(types[start-1], types[start]) > 0 { + runStarts = append(runStarts, start) + } lastType = t } } if len(types) >= 2 { // Sort and deduplicate types - slices.SortStableFunc(types, CompareTypes) + if len(runStarts) != 0 { + c.mergeSortedRuns(types, runStarts) + } unique := 1 for _, t := range types[1:] { if t != types[unique-1] { @@ -26570,9 +26581,69 @@ func (c *Checker) addTypesToUnion(sourceTypes []*Type, types []*Type) ([]*Type, clear(types[unique:]) types = types[:unique] } + c.unionRunStarts = runStarts[:0] return types, includes } +// mergeSortedRuns sorts types, which consists of consecutive runs that are each already sorted. +// runStarts holds the start index of every run except the first, and is reused as scratch space. +func (c *Checker) mergeSortedRuns(types []*Type, runStarts []int) { + n := len(types) + scratch := c.getTemporaryTypeSet() + defer c.releaseTemporaryTypeSet(scratch) + scratch.values = slices.Grow(scratch.values[:0], n)[:n] + src, dst := types, scratch.values + for len(runStarts) != 0 { + runs := len(runStarts) + 1 + runBound := func(i int) int { + switch { + case i == 0: + return 0 + case i == runs: + return n + } + return runStarts[i-1] + } + // Merge adjacent pairs of runs, compacting the start indices of the merged runs in place. + merged := 0 + for i := 0; i < runs; i += 2 { + lo := runBound(i) + mid := runBound(i + 1) + hi := mid + if i+1 < runs { + hi = runBound(i + 2) + } + mergeSortedTypes(dst[lo:hi], src[lo:mid], src[mid:hi]) + if i != 0 { + runStarts[merged] = lo + merged++ + } + } + runStarts = runStarts[:merged] + src, dst = dst, src + } + if &src[0] != &types[0] { + copy(types, src) + } +} + +// mergeSortedTypes merges the sorted lists left and right into dst, preferring left on ties. +func mergeSortedTypes(dst []*Type, left []*Type, right []*Type) { + i, j, k := 0, 0, 0 + for i < len(left) && j < len(right) { + if CompareTypes(right[j], left[i]) < 0 { + dst[k] = right[j] + j++ + } else { + dst[k] = left[i] + i++ + } + k++ + } + k += copy(dst[k:], left[i:]) + copy(dst[k:], right[j:]) +} + func (c *Checker) addNamedUnions(namedUnions []*Type, types []*Type) []*Type { for _, t := range types { if t.flags&TypeFlagsUnion != 0 { From 92379bec14834531754ddc9835c009b945f566bc Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:38:49 -0700 Subject: [PATCH 2/5] Avoid binary searches in containsType where possible containsType is called frequently on union constituent lists (during relation checks, narrowing, and union reduction) and always did a binary search, paying for several CompareTypes calls even when the answer is obvious. Since type lists are sorted primarily by their sort order flags, a type whose flags fall outside the range spanned by the first and last elements cannot be in the list, which can be decided with two cheap flag reads. For short lists, a linear identity scan is also cheaper than the comparisons a binary search requires. Co-authored-by: Steven Chong <25894545+teamchong@users.noreply.github.com> --- tsc/internal/checker/checker.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index f09149b844f02..5126149e540d4 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -27442,6 +27442,20 @@ func (c *Checker) removeType(t *Type, targetType *Type) *Type { } func containsType(types []*Type, t *Type) bool { + n := len(types) + if n == 0 { + return false + } + // Types are sorted primarily by sort order flags, so t can't be present when its flags fall outside + // the range spanned by the list. + flags := getSortOrderFlags(t) + if flags < getSortOrderFlags(types[0]) || flags > getSortOrderFlags(types[n-1]) { + return false + } + // For short lists, scanning by identity is cheaper than the CompareTypes calls of a binary search. + if n <= 16 { + return slices.Contains(types, t) + } _, ok := slices.BinarySearchFunc(types, t, CompareTypes) return ok } From b3439e78ce0e3b9c86232b9d1fe98ac3d847e78b Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:44:49 -0700 Subject: [PATCH 3/5] Check only neighbors for regular literals in union reduction When reducing a union, removeRedundantLiteralTypes drops a fresh literal type if its regular counterpart is also present, and did so by binary searching the whole type set for every fresh literal. Unions of many fresh literals (common for inferred literal-heavy expressions) therefore paid a full search per member. A fresh literal type and its regular type have the same flags, symbol and value, so CompareTypes only distinguishes them by type ID, and no other type can sort between them. The regular type, if present, must therefore be immediately before or after the fresh type in the sorted set, which can be checked directly. Co-authored-by: Steven Chong <25894545+teamchong@users.noreply.github.com> --- tsc/internal/checker/checker.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index 5126149e540d4..3601301de15a4 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -26669,7 +26669,7 @@ func (c *Checker) removeRedundantLiteralTypes(types []*Type, includes TypeFlags, flags&TypeFlagsBigIntLiteral != 0 && includes&TypeFlagsBigInt != 0 || flags&TypeFlagsUniqueESSymbol != 0 && includes&TypeFlagsESSymbol != 0 || reduceVoidUndefined && flags&TypeFlagsUndefined != 0 && includes&TypeFlagsVoid != 0 || - isFreshLiteralType(t) && containsType(types, t.AsLiteralType().regularType) + isFreshLiteralType(t) && containsAdjacentType(types, i, t.AsLiteralType().regularType) if remove { types = slices.Delete(types, i, i+1) } @@ -26677,6 +26677,12 @@ func (c *Checker) removeRedundantLiteralTypes(types []*Type, includes TypeFlags, return types } +// containsAdjacentType reports whether t is a neighbor of types[i] in the sorted list types. A fresh literal +// type and its regular counterpart differ only in type ID, so CompareTypes orders them next to each other. +func containsAdjacentType(types []*Type, i int, t *Type) bool { + return i > 0 && types[i-1] == t || i+1 < len(types) && types[i+1] == t +} + func (c *Checker) removeStringLiteralsMatchedByTemplateLiterals(types []*Type) []*Type { templates := core.Filter(types, c.isPatternLiteralType) if len(templates) != 0 { From 181cb2a8f503c60de77741abe2672d9a6e92ace9 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:46:47 -0700 Subject: [PATCH 4/5] Prefilter string literals by template literal prefix and suffix When a union contains both string literals and pattern template literal types, every string literal is tested against every template to see whether it is subsumed. Each test goes through template literal inference, which allocates and does a fair amount of work before discovering that most literals do not even begin and end with the template's fixed leading and trailing text. Check those fixed texts directly first. This is the same condition inference rejects on, so only literals that could actually match pay for the full check. Co-authored-by: Steven Chong <25894545+teamchong@users.noreply.github.com> --- tsc/internal/checker/checker.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index 3601301de15a4..1d77934a35799 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -26691,7 +26691,7 @@ func (c *Checker) removeStringLiteralsMatchedByTemplateLiterals(types []*Type) [ i-- t := types[i] if t.flags&TypeFlagsStringLiteral != 0 && core.Some(templates, func(template *Type) bool { - return c.isTypeMatchedByTemplateLiteralOrStringMapping(t, template) + return couldStringLiteralMatchTemplate(t, template) && c.isTypeMatchedByTemplateLiteralOrStringMapping(t, template) }) { types = slices.Delete(types, i, i+1) } @@ -26700,6 +26700,18 @@ func (c *Checker) removeStringLiteralsMatchedByTemplateLiterals(types []*Type) [ return types } +// couldStringLiteralMatchTemplate cheaply rejects a string literal whose value lacks the leading and trailing +// text of a template literal type, which inferFromLiteralPartsToTemplateLiteral requires for a match. +func couldStringLiteralMatchTemplate(t *Type, template *Type) bool { + if template.flags&TypeFlagsTemplateLiteral == 0 { + return true + } + value := getStringLiteralValue(t) + texts := template.AsTemplateLiteralType().texts + start, end := texts[0], texts[len(texts)-1] + return len(value) >= len(start)+len(end) && strings.HasPrefix(value, start) && strings.HasSuffix(value, end) +} + func (c *Checker) isTypeMatchedByTemplateLiteralOrStringMapping(t *Type, template *Type) bool { if template.flags&TypeFlagsTemplateLiteral != 0 { return c.isTypeMatchedByTemplateLiteralType(t, template.AsTemplateLiteralType(), c.compareTypesAssignable) From f8da73ee73fc5ebaec8ef94092a1e2c8ea6ed395 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:46:51 -0700 Subject: [PATCH 5/5] Drop oversized union run buffers instead of retaining them The run-start buffer is kept on the checker for reuse across union constructions. Inputs with many short sorted runs, such as alternating primitives, can grow it far beyond the size of the resulting union, and that capacity would then be retained for the checker's lifetime. Discard buffers above the same 4096-element cutoff used for temporary type sets, while still reusing smaller ones. --- tsc/internal/checker/checker.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index 1d77934a35799..f69ee1b61a5e7 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -26581,7 +26581,12 @@ func (c *Checker) addTypesToUnion(sourceTypes []*Type, types []*Type) ([]*Type, clear(types[unique:]) types = types[:unique] } - c.unionRunStarts = runStarts[:0] + // Do not retain arbitrarily large run buffers, matching releaseTemporaryTypeSet. + if cap(runStarts) <= 4096 { + c.unionRunStarts = runStarts[:0] + } else { + c.unionRunStarts = nil + } return types, includes }