From 2af60b302cdf197fbe6aa4a799dcb4bfdf0748c7 Mon Sep 17 00:00:00 2001 From: Sebastian Sebbie Silbermann Date: Sun, 29 Mar 2026 13:44:08 -0700 Subject: [PATCH 1/3] Use trie for removeStringLiteralsMatchedByTemplateLiterals Optimize removeStringLiteralsMatchedByTemplateLiterals by building a prefix trie from TemplateLiteralType patterns and using O(L) trie traversal per string literal instead of O(m) linear scan across all templates. StringMappingType templates (which cannot be trie-indexed) are checked separately. Co-Authored-By: Claude Opus 4.6 (1M context) --- tsc/internal/checker/checker.go | 52 ++++++++++++++++++++++++--------- tsc/internal/checker/relater.go | 47 +++++++++++++++++++++++++++++ tsc/internal/checker/types.go | 5 ++++ 3 files changed, 90 insertions(+), 14 deletions(-) diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index 2a4ead5003a89..5c40517e85c68 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -25979,26 +25979,50 @@ func (c *Checker) removeRedundantLiteralTypes(types []*Type, includes TypeFlags, func (c *Checker) removeStringLiteralsMatchedByTemplateLiterals(types []*Type) []*Type { templates := core.Filter(types, c.isPatternLiteralType) - if len(templates) != 0 { - i := len(types) - for i > 0 { - i-- - t := types[i] - if t.flags&TypeFlagsStringLiteral != 0 && core.Some(templates, func(template *Type) bool { - return c.isTypeMatchedByTemplateLiteralOrStringMapping(t, template) - }) { - types = slices.Delete(types, i, i+1) - } + if len(templates) == 0 { + return types + } + templateLiterals := core.Filter(templates, func(t *Type) bool { + return t.flags&TypeFlagsTemplateLiteral != 0 + }) + stringMappings := core.Filter(templates, func(t *Type) bool { + return t.flags&TypeFlagsStringMapping != 0 + }) + var trie *templateLiteralTrieNode + if len(templateLiterals) >= 2 { + trie = c.buildTemplateLiteralTrieFromTypes(templateLiterals) + } + i := len(types) + for i > 0 { + i-- + t := types[i] + if t.flags&TypeFlagsStringLiteral != 0 && c.isStringLiteralMatchedByTemplates(t, trie, templateLiterals, stringMappings) { + types = slices.Delete(types, i, i+1) } } return types } -func (c *Checker) isTypeMatchedByTemplateLiteralOrStringMapping(t *Type, template *Type) bool { - if template.flags&TypeFlagsTemplateLiteral != 0 { - return c.isTypeMatchedByTemplateLiteralType(t, template.AsTemplateLiteralType(), c.compareTypesAssignable) +func (c *Checker) isStringLiteralMatchedByTemplates(source *Type, trie *templateLiteralTrieNode, templateLiterals []*Type, stringMappings []*Type) bool { + if trie != nil { + if c.findMatchingTemplateLiteralInTrie(trie, source, c.compareTypesAssignable) != nil { + return true + } + } else if len(templateLiterals) > 0 { + if core.Some(templateLiterals, func(tl *Type) bool { + return c.isTypeMatchedByTemplateLiteralType(source, tl.AsTemplateLiteralType(), c.compareTypesAssignable) + }) { + return true + } } - return c.isMemberOfStringMapping(t, template) + if len(stringMappings) > 0 { + if core.Some(stringMappings, func(sm *Type) bool { + return c.isMemberOfStringMapping(source, sm) + }) { + return true + } + } + return false } func (c *Checker) removeConstrainedTypeVariables(types []*Type) []*Type { diff --git a/tsc/internal/checker/relater.go b/tsc/internal/checker/relater.go index 9008420fb7cd1..b839e8c74bffd 100644 --- a/tsc/internal/checker/relater.go +++ b/tsc/internal/checker/relater.go @@ -1105,6 +1105,53 @@ func (c *Checker) getMatchingUnionConstituentForType(unionType *Type, t *Type) * return c.getConstituentTypeForKeyType(unionType, propType) } +func (c *Checker) buildTemplateLiteralTrieFromTypes(templateTypes []*Type) *templateLiteralTrieNode { + root := &templateLiteralTrieNode{} + for _, t := range templateTypes { + prefix := t.AsTemplateLiteralType().texts[0] + node := root + for _, ch := range []byte(prefix) { + if node.children == nil { + node.children = make(map[byte]*templateLiteralTrieNode) + } + child := node.children[ch] + if child == nil { + child = &templateLiteralTrieNode{} + node.children[ch] = child + } + node = child + } + node.types = append(node.types, t) + } + return root +} + +func (c *Checker) findMatchingTemplateLiteralInTrie(trie *templateLiteralTrieNode, source *Type, compareTypes TypeComparer) *Type { + value := source.AsLiteralType().Value().(string) + node := trie + // Check root candidates (empty-prefix templates like `${string}`) + for _, t := range node.types { + if c.isTypeMatchedByTemplateLiteralType(source, t.AsTemplateLiteralType(), compareTypes) { + return t + } + } + for _, ch := range []byte(value) { + if node.children == nil { + return nil + } + node = node.children[ch] + if node == nil { + return nil + } + for _, t := range node.types { + if c.isTypeMatchedByTemplateLiteralType(source, t.AsTemplateLiteralType(), compareTypes) { + return t + } + } + } + return nil +} + // Return the name of a discriminant property for which it was possible and feasible to construct a map of // constituent types keyed by the literal types of the property by that name in each constituent type. Return // an empty string if no such discriminant property exists. diff --git a/tsc/internal/checker/types.go b/tsc/internal/checker/types.go index 144124e63c598..9e05c7ae23f18 100644 --- a/tsc/internal/checker/types.go +++ b/tsc/internal/checker/types.go @@ -1218,6 +1218,11 @@ type TemplateLiteralType struct { func (t *TemplateLiteralType) Texts() []string { return t.texts } func (t *TemplateLiteralType) Types() []*Type { return t.types } +type templateLiteralTrieNode struct { + children map[byte]*templateLiteralTrieNode + types []*Type // template literal types whose prefix ends at this node +} + type StringMappingType struct { ConstrainedType target *Type From 16a1b44410f83ec9d1becdb7d138f6bb1d31d470 Mon Sep 17 00:00:00 2001 From: Sebastian Sebbie Silbermann Date: Tue, 18 Aug 2026 23:18:19 +0200 Subject: [PATCH 2/3] Allocate template literal trie nodes from an arena The prefix trie built by `buildTemplateLiteralTrieFromTypes` is constructed fresh on every call, and each node was its own small heap allocation. This change routes those nodes through a function-local `core.Arena[templateLiteralTrieNode]`, which batches them into a single backing array that grows by doubling, so building a trie of N nodes costs O(log N) allocations instead of N. Returning pointers into a function-local arena is safe here because `Arena.New` hands out pointers into a heap-allocated slice, and growth allocates a fresh array rather than copying into a larger one. Previously returned nodes are therefore never invalidated, and they stay reachable through the returned root. Co-Authored-By: Claude Code (kimi-k3[1m]) --- tsc/internal/checker/relater.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tsc/internal/checker/relater.go b/tsc/internal/checker/relater.go index b839e8c74bffd..135fe4fbe10df 100644 --- a/tsc/internal/checker/relater.go +++ b/tsc/internal/checker/relater.go @@ -1106,7 +1106,8 @@ func (c *Checker) getMatchingUnionConstituentForType(unionType *Type, t *Type) * } func (c *Checker) buildTemplateLiteralTrieFromTypes(templateTypes []*Type) *templateLiteralTrieNode { - root := &templateLiteralTrieNode{} + var arena core.Arena[templateLiteralTrieNode] + root := arena.New() for _, t := range templateTypes { prefix := t.AsTemplateLiteralType().texts[0] node := root @@ -1116,7 +1117,7 @@ func (c *Checker) buildTemplateLiteralTrieFromTypes(templateTypes []*Type) *temp } child := node.children[ch] if child == nil { - child = &templateLiteralTrieNode{} + child = arena.New() node.children[ch] = child } node = child From 72114fcb94f7a1e02199bf0c47a8b9c8e383a02b Mon Sep 17 00:00:00 2001 From: Sebastian Sebbie Silbermann Date: Thu, 20 Aug 2026 19:05:23 +0200 Subject: [PATCH 3/3] Discriminate the template literal trie by final static text and gate its construction The trie indexed templates only by their first static text, so patterns that share a prefix (every dynamic route in #63342 starts with "/") still placed all templates in one bucket and scanned them linearly per string literal. Each prefix node now stores its templates in a nested trie keyed by the reversed final static text, and candidates are skipped when the static prefix and suffix would have to overlap in the literal. Both filters are sound because they mirror the necessary conditions inferFromLiteralPartsToTemplateLiteral already checks. On the issue's repro this reduces check time from ~0.31s to ~0.25s compared with the prefix-only trie (the base without any trie takes ~8.5s). Benchmarking both matching paths showed that building the trie was a pessimization for small unions: with map-based child tables, even a handful of templates cost more to index than the linear scan they replaced. Child tables are now small slices scanned linearly, which reduced construction cost by roughly 3-5x, and removeStringLiteralsMatchedByTemplateLiterals only builds the trie once the expected comparison count (templates times string literals) reaches 512, the crossover measured by the new BenchmarkTemplateLiteralMatchingPaths. Templates that all share their first and final static text stay on the linear path entirely, since the trie cannot discriminate between them and measured 30-40% slower at large sizes. The gate check itself is a flag-count pass plus a short string-equality scan, far cheaper than building even a two-template trie. Adds a compiler regression test with overlapping template prefixes, matching and non-matching literals, and a string mapping constituent, plus unit tests that cover the trie directly regardless of the gate. Co-Authored-By: Claude Code (kimi-k3[1m]) --- tsc/internal/checker/checker.go | 34 ++- tsc/internal/checker/relater.go | 119 +++++++-- .../template_literal_trie_bench_test.go | 250 ++++++++++++++++++ .../checker/template_literal_trie_test.go | 122 +++++++++ tsc/internal/checker/types.go | 24 +- .../templateLiteralUnionReduction.errors.txt | 93 +++++++ .../templateLiteralUnionReduction.symbols | 95 +++++++ .../templateLiteralUnionReduction.types | 98 +++++++ .../compiler/templateLiteralUnionReduction.ts | 83 ++++++ 9 files changed, 890 insertions(+), 28 deletions(-) create mode 100644 tsc/internal/checker/template_literal_trie_bench_test.go create mode 100644 tsc/internal/checker/template_literal_trie_test.go create mode 100644 tsc/testdata/baselines/reference/compiler/templateLiteralUnionReduction.errors.txt create mode 100644 tsc/testdata/baselines/reference/compiler/templateLiteralUnionReduction.symbols create mode 100644 tsc/testdata/baselines/reference/compiler/templateLiteralUnionReduction.types create mode 100644 tsc/testdata/tests/cases/compiler/templateLiteralUnionReduction.ts diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index 5c40517e85c68..a427b3d12a531 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -25977,6 +25977,12 @@ func (c *Checker) removeRedundantLiteralTypes(types []*Type, includes TypeFlags, return types } +// templateLiteralTrieMinComparisons is the number of template-vs-literal comparisons a +// linear scan would perform at which building the template literal trie starts to pay +// for its construction cost, as measured by +// BenchmarkTemplateLiteralMatchingPaths/BenchmarkRemoveStringLiteralsMatchedByTemplateLiterals. +const templateLiteralTrieMinComparisons = 512 + func (c *Checker) removeStringLiteralsMatchedByTemplateLiterals(types []*Type) []*Type { templates := core.Filter(types, c.isPatternLiteralType) if len(templates) == 0 { @@ -25989,8 +25995,16 @@ func (c *Checker) removeStringLiteralsMatchedByTemplateLiterals(types []*Type) [ return t.flags&TypeFlagsStringMapping != 0 }) var trie *templateLiteralTrieNode - if len(templateLiterals) >= 2 { - trie = c.buildTemplateLiteralTrieFromTypes(templateLiterals) + if len(templateLiterals) >= 2 && isTemplateLiteralTrieSelective(templateLiterals) { + stringLiteralCount := 0 + for _, t := range types { + if t.flags&TypeFlagsStringLiteral != 0 { + stringLiteralCount++ + } + } + if len(templateLiterals)*stringLiteralCount >= templateLiteralTrieMinComparisons { + trie = c.buildTemplateLiteralTrieFromTypes(templateLiterals) + } } i := len(types) for i > 0 { @@ -26003,6 +26017,22 @@ func (c *Checker) removeStringLiteralsMatchedByTemplateLiterals(types []*Type) [ return types } +// isTemplateLiteralTrieSelective reports whether the templates differ in their first or +// final static text. If they do not, the trie cannot discriminate between them and only +// adds construction and traversal overhead over the linear scan. +func isTemplateLiteralTrieSelective(templateLiterals []*Type) bool { + firstTexts := templateLiterals[0].AsTemplateLiteralType().texts + firstPrefix := firstTexts[0] + firstSuffix := firstTexts[len(firstTexts)-1] + for _, t := range templateLiterals[1:] { + texts := t.AsTemplateLiteralType().texts + if texts[0] != firstPrefix || texts[len(texts)-1] != firstSuffix { + return true + } + } + return false +} + func (c *Checker) isStringLiteralMatchedByTemplates(source *Type, trie *templateLiteralTrieNode, templateLiterals []*Type, stringMappings []*Type) bool { if trie != nil { if c.findMatchingTemplateLiteralInTrie(trie, source, c.compareTypesAssignable) != nil { diff --git a/tsc/internal/checker/relater.go b/tsc/internal/checker/relater.go index 135fe4fbe10df..d9fd3aa2a8e55 100644 --- a/tsc/internal/checker/relater.go +++ b/tsc/internal/checker/relater.go @@ -1107,47 +1107,118 @@ func (c *Checker) getMatchingUnionConstituentForType(unionType *Type, t *Type) * func (c *Checker) buildTemplateLiteralTrieFromTypes(templateTypes []*Type) *templateLiteralTrieNode { var arena core.Arena[templateLiteralTrieNode] + var suffixArena core.Arena[templateLiteralSuffixTrieNode] root := arena.New() for _, t := range templateTypes { - prefix := t.AsTemplateLiteralType().texts[0] + texts := t.AsTemplateLiteralType().texts node := root - for _, ch := range []byte(prefix) { - if node.children == nil { - node.children = make(map[byte]*templateLiteralTrieNode) - } - child := node.children[ch] - if child == nil { - child = arena.New() - node.children[ch] = child - } - node = child + for i := range len(texts[0]) { + node = node.getOrInsertChild(&arena, texts[0][i]) + } + if node.bySuffix == nil { + node.bySuffix = suffixArena.New() + } + // A string literal can only match a template literal if it ends with the template's + // final static text, so candidates are indexed by that text reversed. + suffixNode := node.bySuffix + suffix := texts[len(texts)-1] + for i := len(suffix) - 1; i >= 0; i-- { + suffixNode = suffixNode.getOrInsertChild(&suffixArena, suffix[i]) } - node.types = append(node.types, t) + suffixNode.types = append(suffixNode.types, t) } return root } +func (n *templateLiteralTrieNode) getOrInsertChild(arena *core.Arena[templateLiteralTrieNode], ch byte) *templateLiteralTrieNode { + for _, edge := range n.children { + if edge.b == ch { + return edge.node + } + } + child := arena.New() + n.children = append(n.children, templateLiteralTrieEdge{b: ch, node: child}) + return child +} + +func (n *templateLiteralTrieNode) findChild(ch byte) *templateLiteralTrieNode { + for _, edge := range n.children { + if edge.b == ch { + return edge.node + } + } + return nil +} + +func (n *templateLiteralSuffixTrieNode) getOrInsertChild(arena *core.Arena[templateLiteralSuffixTrieNode], ch byte) *templateLiteralSuffixTrieNode { + for _, edge := range n.children { + if edge.b == ch { + return edge.node + } + } + child := arena.New() + n.children = append(n.children, templateLiteralSuffixTrieEdge{b: ch, node: child}) + return child +} + +func (n *templateLiteralSuffixTrieNode) findChild(ch byte) *templateLiteralSuffixTrieNode { + for _, edge := range n.children { + if edge.b == ch { + return edge.node + } + } + return nil +} + func (c *Checker) findMatchingTemplateLiteralInTrie(trie *templateLiteralTrieNode, source *Type, compareTypes TypeComparer) *Type { value := source.AsLiteralType().Value().(string) node := trie - // Check root candidates (empty-prefix templates like `${string}`) - for _, t := range node.types { - if c.isTypeMatchedByTemplateLiteralType(source, t.AsTemplateLiteralType(), compareTypes) { - return t - } + // Check root candidates (empty-prefix templates like `${number}`) + if t := c.findMatchingTemplateLiteralBySuffix(node.bySuffix, value, 0, source, compareTypes); t != nil { + return t } - for _, ch := range []byte(value) { - if node.children == nil { + for i := range len(value) { + node = node.findChild(value[i]) + if node == nil { return nil } - node = node.children[ch] + if t := c.findMatchingTemplateLiteralBySuffix(node.bySuffix, value, i+1, source, compareTypes); t != nil { + return t + } + } + return nil +} + +func (c *Checker) findMatchingTemplateLiteralBySuffix(trie *templateLiteralSuffixTrieNode, value string, prefixLength int, source *Type, compareTypes TypeComparer) *Type { + if trie == nil { + return nil + } + // Candidates with an empty final static text (e.g. `/${string}`) are stored at the root. + if t := c.matchTemplateLiteralTrieCandidates(trie.types, value, prefixLength, 0, source, compareTypes); t != nil { + return t + } + node := trie + for i := len(value) - 1; i >= 0; i-- { + node = node.findChild(value[i]) if node == nil { return nil } - for _, t := range node.types { - if c.isTypeMatchedByTemplateLiteralType(source, t.AsTemplateLiteralType(), compareTypes) { - return t - } + if t := c.matchTemplateLiteralTrieCandidates(node.types, value, prefixLength, len(value)-i, source, compareTypes); t != nil { + return t + } + } + return nil +} + +func (c *Checker) matchTemplateLiteralTrieCandidates(candidates []*Type, value string, prefixLength int, suffixLength int, source *Type, compareTypes TypeComparer) *Type { + // The first and last static texts cannot overlap in the source, so a shorter string + // can never match (mirrors the guard in inferFromLiteralPartsToTemplateLiteral). + if prefixLength+suffixLength > len(value) { + return nil + } + for _, t := range candidates { + if c.isTypeMatchedByTemplateLiteralType(source, t.AsTemplateLiteralType(), compareTypes) { + return t } } return nil diff --git a/tsc/internal/checker/template_literal_trie_bench_test.go b/tsc/internal/checker/template_literal_trie_bench_test.go new file mode 100644 index 0000000000000..efedfd6fcf8c8 --- /dev/null +++ b/tsc/internal/checker/template_literal_trie_bench_test.go @@ -0,0 +1,250 @@ +package checker + +import ( + "fmt" + "slices" + "strconv" + "strings" + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/ast" + "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/module" + "github.com/microsoft/TypeScript/tsc/internal/packagejson" + "github.com/microsoft/TypeScript/tsc/internal/symlinks" + "github.com/microsoft/TypeScript/tsc/internal/tsoptions" + "github.com/microsoft/TypeScript/tsc/internal/tspath" +) + +// templateLiteralTestProgram is a minimal Program implementation, just enough to +// construct a Checker for testing template literal union reduction without +// importing the compiler package (which would create an import cycle). +type templateLiteralTestProgram struct { + options *core.CompilerOptions +} + +func (p *templateLiteralTestProgram) Options() *core.CompilerOptions { return p.options } +func (p *templateLiteralTestProgram) SourceFiles() []*ast.SourceFile { return nil } +func (p *templateLiteralTestProgram) BindSourceFiles() {} +func (p *templateLiteralTestProgram) FileExists(fileName string) bool { + return false +} + +func (p *templateLiteralTestProgram) GetSourceFile(fileName string) *ast.SourceFile { + return nil +} + +func (p *templateLiteralTestProgram) GetSourceFileForResolvedModule(fileName string) *ast.SourceFile { + return nil +} + +func (p *templateLiteralTestProgram) GetEmitModuleFormatOfFile(sourceFile ast.HasFileName) core.ModuleKind { + return core.ModuleKindESNext +} + +func (p *templateLiteralTestProgram) GetEmitSyntaxForUsageLocation(sourceFile ast.HasFileName, usageLocation *ast.StringLiteralLike) core.ResolutionMode { + return core.ResolutionModeNone +} + +func (p *templateLiteralTestProgram) GetImpliedNodeFormatForEmit(sourceFile ast.HasFileName) core.ModuleKind { + return core.ModuleKindESNext +} + +func (p *templateLiteralTestProgram) GetResolvedModule(currentSourceFile ast.HasFileName, moduleReference string, mode core.ResolutionMode) *module.ResolvedModule { + return nil +} + +func (p *templateLiteralTestProgram) GetResolvedModules() map[tspath.Path]module.ModeAwareCache[*module.ResolvedModule] { + return nil +} +func (p *templateLiteralTestProgram) GetPackagesMap() map[string]bool { return nil } +func (p *templateLiteralTestProgram) GetSourceFileMetaData(path tspath.Path) ast.SourceFileMetaData { + return ast.SourceFileMetaData{} +} + +func (p *templateLiteralTestProgram) GetJSXRuntimeImportSpecifier(path tspath.Path) (moduleReference string, specifier *ast.Node) { + return "", nil +} + +func (p *templateLiteralTestProgram) GetImportHelpersImportSpecifier(path tspath.Path) *ast.Node { + return nil +} + +func (p *templateLiteralTestProgram) SourceFileMayBeEmitted(sourceFile *ast.SourceFile, forceDtsEmit bool) bool { + return false +} + +func (p *templateLiteralTestProgram) IsSourceFileDefaultLibrary(path tspath.Path) bool { + return false +} + +func (p *templateLiteralTestProgram) GetProjectReferenceFromOutputDts(path tspath.Path) *tsoptions.SourceOutputAndProjectReference { + return nil +} + +func (p *templateLiteralTestProgram) GetRedirectForResolution(file ast.HasFileName) *tsoptions.ParsedCommandLine { + return nil +} +func (p *templateLiteralTestProgram) CommonSourceDirectory() string { return "" } +func (p *templateLiteralTestProgram) GetSymlinkCache() *symlinks.KnownSymlinks { + return nil +} +func (p *templateLiteralTestProgram) ContentMapperExtensions() []string { return nil } +func (p *templateLiteralTestProgram) GetGlobalTypingsCacheLocation() string { return "" } +func (p *templateLiteralTestProgram) UseCaseSensitiveFileNames() bool { return false } +func (p *templateLiteralTestProgram) GetCurrentDirectory() string { return "" } +func (p *templateLiteralTestProgram) GetProjectReferenceFromSource(path tspath.Path) *tsoptions.SourceOutputAndProjectReference { + return nil +} +func (p *templateLiteralTestProgram) GetRedirectTargets(path tspath.Path) []string { return nil } +func (p *templateLiteralTestProgram) GetSourceOfProjectReferenceIfOutputIncluded(file ast.HasFileName) string { + return "" +} + +func (p *templateLiteralTestProgram) GetNearestAncestorDirectoryWithPackageJson(dirname string) string { + return "" +} + +func (p *templateLiteralTestProgram) GetPackageJsonInfo(pkgJsonPath string) *packagejson.InfoCacheEntry { + return nil +} + +func (p *templateLiteralTestProgram) GetDefaultResolutionModeForFile(file ast.HasFileName) core.ResolutionMode { + return core.ResolutionModeNone +} + +func (p *templateLiteralTestProgram) GetResolvedModuleFromModuleSpecifier(file ast.HasFileName, moduleSpecifier *ast.StringLiteralLike) *module.ResolvedModule { + return nil +} + +func (p *templateLiteralTestProgram) GetModeForUsageLocation(file ast.HasFileName, moduleSpecifier *ast.StringLiteralLike) core.ResolutionMode { + return core.ResolutionModeNone +} + +// buildTemplateLiteralUnionTypes creates `literals` string literal types and `templates` +// template literal types for one of three shapes: +// +// - "sharedPrefixDistinctSuffix": every template is `/${string}/section`, shaped +// like the dynamic route patterns from +// https://github.com/microsoft/TypeScript/issues/63342. The shared "/" prefix cannot +// discriminate between templates; only the final static text can. +// - "sharedPrefixEmptySuffix": template i consists of i+1 `/${string}` segments with +// no final static text, so neither prefix nor suffix can discriminate. This is the +// worst case for the trie: every candidate bucket holds every template. +// - "distinctPrefix": every template is `/route/${string}` with its own prefix, +// like `${StaticRoute}${Search}` patterns. The prefix trie prunes aggressively. +// +// In the "sharedPrefixDistinctSuffix" and "distinctPrefix" shapes, half of the literals +// match one template each and the other half match nothing. +func buildTemplateLiteralUnionTypes(c *Checker, shape string, literals int, templates int) (types []*Type, literalTypes []*Type, templateTypes []*Type) { + for i := range literals { + var value string + matching := i%2 == 0 && i/2 < templates + switch shape { + case "sharedPrefixDistinctSuffix": + if matching { + value = "/slug/section" + strconv.Itoa(i/2) + } else { + value = "/slug/other" + strconv.Itoa(i) + } + case "sharedPrefixEmptySuffix": + segments := 1 + if matching { + segments = i/2 + 1 + } + var sb strings.Builder + for j := range segments { + sb.WriteString("/slug") + sb.WriteString(strconv.Itoa(j)) + } + value = sb.String() + case "distinctPrefix": + if matching { + value = "/route" + strconv.Itoa(i/2) + "/rest" + } else { + value = "/other" + strconv.Itoa(i) + "/rest" + } + } + t := c.getStringLiteralType(value) + types = append(types, t) + literalTypes = append(literalTypes, t) + } + for i := range templates { + var t *Type + switch shape { + case "sharedPrefixDistinctSuffix": + t = c.getTemplateLiteralType([]string{"/", "/section" + strconv.Itoa(i)}, []*Type{c.stringType}) + case "sharedPrefixEmptySuffix": + texts := make([]string, i+2) + placeholders := make([]*Type, i+1) + for j := range texts { + texts[j] = "/" + } + texts[len(texts)-1] = "" + for j := range placeholders { + placeholders[j] = c.stringType + } + t = c.getTemplateLiteralType(texts, placeholders) + case "distinctPrefix": + t = c.getTemplateLiteralType([]string{"/route" + strconv.Itoa(i) + "/", ""}, []*Type{c.stringType}) + } + types = append(types, t) + templateTypes = append(templateTypes, t) + } + return types, literalTypes, templateTypes +} + +var benchmarkShapes = []string{"sharedPrefixDistinctSuffix", "sharedPrefixEmptySuffix", "distinctPrefix"} + +func BenchmarkRemoveStringLiteralsMatchedByTemplateLiterals(b *testing.B) { + c, _ := NewChecker(&templateLiteralTestProgram{options: &core.CompilerOptions{}}, nil) + for _, shape := range benchmarkShapes { + for _, literals := range []int{1, 2, 4, 8, 16, 32, 64, 128} { + for _, templates := range []int{1, 2, 4, 8, 16, 32, 64, 128} { + types, _, _ := buildTemplateLiteralUnionTypes(c, shape, literals, templates) + b.Run(fmt.Sprintf("%s/literals=%d/templates=%d", shape, literals, templates), func(b *testing.B) { + var sink []*Type + for b.Loop() { + sink = c.removeStringLiteralsMatchedByTemplateLiterals(slices.Clone(types)) + } + _ = sink + }) + } + } + } +} + +// BenchmarkTemplateLiteralMatchingPaths compares the two matching strategies directly, +// including the per-call trie construction in the trie path, to determine the union +// sizes at which building the trie pays off. +func BenchmarkTemplateLiteralMatchingPaths(b *testing.B) { + c, _ := NewChecker(&templateLiteralTestProgram{options: &core.CompilerOptions{}}, nil) + for _, shape := range benchmarkShapes { + for _, literals := range []int{1, 2, 4, 8, 16, 32, 64, 128} { + for _, templates := range []int{1, 2, 4, 8, 16, 32, 64, 128} { + _, literalTypes, templateTypes := buildTemplateLiteralUnionTypes(c, shape, literals, templates) + b.Run(fmt.Sprintf("%s/literals=%d/templates=%d/linear", shape, literals, templates), func(b *testing.B) { + var sink bool + for b.Loop() { + for _, lit := range literalTypes { + sink = core.Some(templateTypes, func(tl *Type) bool { + return c.isTypeMatchedByTemplateLiteralType(lit, tl.AsTemplateLiteralType(), c.compareTypesAssignable) + }) + } + } + _ = sink + }) + b.Run(fmt.Sprintf("%s/literals=%d/templates=%d/trie", shape, literals, templates), func(b *testing.B) { + var sink *Type + for b.Loop() { + trie := c.buildTemplateLiteralTrieFromTypes(templateTypes) + for _, lit := range literalTypes { + sink = c.findMatchingTemplateLiteralInTrie(trie, lit, c.compareTypesAssignable) + } + } + _ = sink + }) + } + } + } +} diff --git a/tsc/internal/checker/template_literal_trie_test.go b/tsc/internal/checker/template_literal_trie_test.go new file mode 100644 index 0000000000000..e2752273a2b68 --- /dev/null +++ b/tsc/internal/checker/template_literal_trie_test.go @@ -0,0 +1,122 @@ +package checker + +import ( + "slices" + "strconv" + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/core" + "gotest.tools/v3/assert" +) + +// assertSameTypes asserts that two type slices contain the same types in the same order. +// Types are interned per Checker, so pointer identity is sufficient. +func assertSameTypes(t *testing.T, got []*Type, want []*Type) { + t.Helper() + assert.Equal(t, len(got), len(want), "got %d types, want %d", len(got), len(want)) + for i := range want { + assert.Assert(t, got[i] == want[i], "type %d differs", i) + } +} + +func newTemplateLiteralTestChecker() *Checker { + c, _ := NewChecker(&templateLiteralTestProgram{options: &core.CompilerOptions{}}, nil) + return c +} + +func TestFindMatchingTemplateLiteralInTrie(t *testing.T) { + t.Parallel() + c := newTemplateLiteralTestChecker() + + billing := c.getTemplateLiteralType([]string{"/app/", "/billing"}, []*Type{c.stringType}) + settings := c.getTemplateLiteralType([]string{"/app/", "/settings"}, []*Type{c.stringType}) + catchAllApp := c.getTemplateLiteralType([]string{"/app/", ""}, []*Type{c.stringType}) + overlap := c.getTemplateLiteralType([]string{"aa", "aa"}, []*Type{c.stringType}) + number := c.getTemplateLiteralType([]string{"", ""}, []*Type{c.numberType}) + + trie := c.buildTemplateLiteralTrieFromTypes([]*Type{billing, settings, catchAllApp, overlap}) + + for _, tc := range []struct { + value string + match bool + }{ + {"/app/acme/billing", true}, + {"/app/acme/settings", true}, + {"/app/anything", true}, // matched by `/app/${string}` + {"/app/", true}, + {"/app", false}, // shorter than the "/app/" prefix + {"/admin/acme/billing", false}, + {"aaa", false}, // `aa${string}aa` cannot match: the static prefix and suffix would overlap + {"aaaa", true}, + {"aa", false}, + {"", false}, + } { + source := c.getStringLiteralType(tc.value) + got := c.findMatchingTemplateLiteralInTrie(trie, source, c.compareTypesAssignable) + assert.Equal(t, got != nil, tc.match, "value %q", tc.value) + } + + // An empty-prefix template like `${number}` is stored at the root and still matches. + trie = c.buildTemplateLiteralTrieFromTypes([]*Type{billing, number}) + assert.Assert(t, c.findMatchingTemplateLiteralInTrie(trie, c.getStringLiteralType("123"), c.compareTypesAssignable) != nil) + assert.Assert(t, c.findMatchingTemplateLiteralInTrie(trie, c.getStringLiteralType("/unrelated"), c.compareTypesAssignable) == nil) +} + +func TestRemoveStringLiteralsMatchedByTemplateLiterals(t *testing.T) { + t.Parallel() + + newTemplate := func(c *Checker, texts ...string) *Type { + types := make([]*Type, len(texts)-1) + for i := range types { + types[i] = c.stringType + } + return c.getTemplateLiteralType(texts, types) + } + + t.Run("small union takes the linear path", func(t *testing.T) { + t.Parallel() + c := newTemplateLiteralTestChecker() + up1 := c.getStringLiteralType("up1") + other := c.getStringLiteralType("other") + up := newTemplate(c, "up", "") + result := c.removeStringLiteralsMatchedByTemplateLiterals([]*Type{up1, other, up}) + assertSameTypes(t, result, []*Type{other, up}) + }) + + t.Run("static prefix and suffix cannot overlap", func(t *testing.T) { + t.Parallel() + c := newTemplateLiteralTestChecker() + aaa := c.getStringLiteralType("aaa") + aaaa := c.getStringLiteralType("aaaa") + pattern := newTemplate(c, "aa", "aa") + result := c.removeStringLiteralsMatchedByTemplateLiterals([]*Type{aaa, aaaa, pattern}) + assertSameTypes(t, result, []*Type{aaa, pattern}) + }) + + t.Run("empty prefix template", func(t *testing.T) { + t.Parallel() + c := newTemplateLiteralTestChecker() + numeric := c.getStringLiteralType("1") + alpha := c.getStringLiteralType("a") + number := c.getTemplateLiteralType([]string{"", ""}, []*Type{c.numberType}) + result := c.removeStringLiteralsMatchedByTemplateLiterals([]*Type{numeric, alpha, number}) + assertSameTypes(t, result, []*Type{alpha, number}) + }) + + t.Run("large union takes the trie path", func(t *testing.T) { + t.Parallel() + c := newTemplateLiteralTestChecker() + // 32 literals x 16 templates with a shared prefix and distinct suffixes meets the + // trie threshold. Even-indexed literals match one template each, odd-indexed + // literals match nothing. + types, _, templateTypes := buildTemplateLiteralUnionTypes(c, "sharedPrefixDistinctSuffix", 32, 16) + result := c.removeStringLiteralsMatchedByTemplateLiterals(slices.Clone(types)) + + expected := make([]*Type, 0, 32) + for i := 1; i < 32; i += 2 { + expected = append(expected, c.getStringLiteralType("/slug/other"+strconv.Itoa(i))) + } + expected = append(expected, templateTypes...) + assertSameTypes(t, result, expected) + }) +} diff --git a/tsc/internal/checker/types.go b/tsc/internal/checker/types.go index 9e05c7ae23f18..9974ada897490 100644 --- a/tsc/internal/checker/types.go +++ b/tsc/internal/checker/types.go @@ -1219,8 +1219,28 @@ func (t *TemplateLiteralType) Texts() []string { return t.texts } func (t *TemplateLiteralType) Types() []*Type { return t.types } type templateLiteralTrieNode struct { - children map[byte]*templateLiteralTrieNode - types []*Type // template literal types whose prefix ends at this node + // Children are stored as small slices scanned linearly: trie nodes in this domain + // have very few children, and a slice avoids a map allocation per node. Keeping + // construction cheap matters because the trie is rebuilt for every union reduction. + children []templateLiteralTrieEdge + // Template literal types whose first static text ends at this node, further indexed + // by their reversed final static text so candidates can be pruned by suffix as well. + bySuffix *templateLiteralSuffixTrieNode +} + +type templateLiteralTrieEdge struct { + b byte + node *templateLiteralTrieNode +} + +type templateLiteralSuffixTrieNode struct { + children []templateLiteralSuffixTrieEdge + types []*Type // template literal types whose reversed final static text ends at this node +} + +type templateLiteralSuffixTrieEdge struct { + b byte + node *templateLiteralSuffixTrieNode } type StringMappingType struct { diff --git a/tsc/testdata/baselines/reference/compiler/templateLiteralUnionReduction.errors.txt b/tsc/testdata/baselines/reference/compiler/templateLiteralUnionReduction.errors.txt new file mode 100644 index 0000000000000..26b26393076c1 --- /dev/null +++ b/tsc/testdata/baselines/reference/compiler/templateLiteralUnionReduction.errors.txt @@ -0,0 +1,93 @@ +templateLiteralUnionReduction.ts(65,1): error TS2322: Type '"/definitely/not/a/route"' is not assignable to type '"/APP/ACME/BILLING" | "/about" | "/admin/acme/invoices" | "/admin/acme/settings/v3" | "/admin/settings" | "/api/ai-playground/sandbox" | "/api/users" | "/app" | "/blog/post" | "/help" | "/login" | "/logout" | "/pricing" | "/signup" | "/status" | "app/acme/billing" | `/admin/${string}/billing` | `/admin/${string}/billing/v2` | `/admin/${string}/settings` | `/admin/${string}/settings/v2` | `/admin/${string}/users/${string}` | `/app/${string}` | `/app/${string}/billing` | `/app/${string}/billing/v2` | `/app/${string}/integrations/${string}/billing` | `/app/${string}/resources/${string}/billing` | `/app/${string}/settings` | `/app/${string}/settings/v2` | `/org/${string}` | `/org/${string}/billing` | `/org/${string}/members/${string}` | `/org/${string}/settings` | `UP${Uppercase}`'. +templateLiteralUnionReduction.ts(74,1): error TS2322: Type '"not-a-member"' is not assignable to type '"other" | `UP${Uppercase}` | `up${string}`'. +templateLiteralUnionReduction.ts(80,1): error TS2322: Type '"aa"' is not assignable to type '"aaa" | `aa${string}aa`'. + + +==== templateLiteralUnionReduction.ts (3 errors) ==== + // https://github.com/microsoft/TypeScript/issues/63342 + + // Route patterns with overlapping static prefixes, modeled after the dynamic routes in + // the linked issue. Several patterns share both their prefix and their number of + // segments, so only the final static text distinguishes them. The union is large enough + // (32 string literals x 16 templates) to exercise the trie-based filtering in union + // reduction, including a string mapping constituent that is checked separately. The + // error message prints the reduced union: literals matched by a template or by the + // string mapping no longer appear. + declare let route: + | `/app/${string}/billing` + | `/app/${string}/settings` + | `/app/${string}/integrations/${string}/billing` + | `/app/${string}/resources/${string}/billing` + | `/admin/${string}/billing` + | `/admin/${string}/settings` + | `/admin/${string}/users/${string}` + | `/app/${string}` + | `/app/${string}/billing/v2` + | `/app/${string}/settings/v2` + | `/admin/${string}/billing/v2` + | `/admin/${string}/settings/v2` + | `/org/${string}/billing` + | `/org/${string}/settings` + | `/org/${string}/members/${string}` + | `/org/${string}` + | Uppercase<`up${string}`> + // matched by one of the templates above + | "/app/acme/billing" + | "/app/acme/settings" + | "/app/acme/integrations/vercel/billing" + | "/app/acme/resources/aws/billing" + | "/admin/acme/billing" + | "/admin/acme/settings" + | "/admin/acme/users/bob" + | "/app/anything" + | "/app/acme/billing/v2" + | "/app/acme/settings/v2" + | "/admin/acme/billing/v2" + | "/admin/acme/settings/v2" + | "/org/acme/billing" + | "/org/acme/settings" + | "/org/acme/members/bob" + | "/org/acme" + // matched by the string mapping + | "UPGRADE" + // matched by nothing + | "/api/ai-playground/sandbox" + | "/api/users" + | "/about" + | "/pricing" + | "/admin/acme/invoices" + | "/admin/acme/settings/v3" + | "/admin/settings" + | "/app" + | "app/acme/billing" + | "/APP/ACME/BILLING" + | "/help" + | "/blog/post" + | "/login" + | "/logout" + | "/signup" + | "/status"; + + route = "/definitely/not/a/route"; + ~~~~~ +!!! error TS2322: Type '"/definitely/not/a/route"' is not assignable to type '"/APP/ACME/BILLING" | "/about" | "/admin/acme/invoices" | "/admin/acme/settings/v3" | "/admin/settings" | "/api/ai-playground/sandbox" | "/api/users" | "/app" | "/blog/post" | "/help" | "/login" | "/logout" | "/pricing" | "/signup" | "/status" | "app/acme/billing" | `/admin/${string}/billing` | `/admin/${string}/billing/v2` | `/admin/${string}/settings` | `/admin/${string}/settings/v2` | `/admin/${string}/users/${string}` | `/app/${string}` | `/app/${string}/billing` | `/app/${string}/billing/v2` | `/app/${string}/integrations/${string}/billing` | `/app/${string}/resources/${string}/billing` | `/app/${string}/settings` | `/app/${string}/settings/v2` | `/org/${string}` | `/org/${string}/billing` | `/org/${string}/members/${string}` | `/org/${string}/settings` | `UP${Uppercase}`'. + + // Small unions take the linear path; semantics are identical. + declare let small: + | "up1" + | "other" + | `up${string}` + | Uppercase<`up${string}`>; + + small = "not-a-member"; + ~~~~~ +!!! error TS2322: Type '"not-a-member"' is not assignable to type '"other" | `UP${Uppercase}` | `up${string}`'. + + // "aaa" does not match `aa${string}aa`: the static prefix and suffix would have to + // overlap. + declare let overlap: "aaa" | "aaaa" | `aa${string}aa`; + + overlap = "aa"; + ~~~~~~~ +!!! error TS2322: Type '"aa"' is not assignable to type '"aaa" | `aa${string}aa`'. + \ No newline at end of file diff --git a/tsc/testdata/baselines/reference/compiler/templateLiteralUnionReduction.symbols b/tsc/testdata/baselines/reference/compiler/templateLiteralUnionReduction.symbols new file mode 100644 index 0000000000000..63d6dd10ad56f --- /dev/null +++ b/tsc/testdata/baselines/reference/compiler/templateLiteralUnionReduction.symbols @@ -0,0 +1,95 @@ +//// [tests/cases/compiler/templateLiteralUnionReduction.ts] //// + +=== templateLiteralUnionReduction.ts === +// https://github.com/microsoft/TypeScript/issues/63342 + +// Route patterns with overlapping static prefixes, modeled after the dynamic routes in +// the linked issue. Several patterns share both their prefix and their number of +// segments, so only the final static text distinguishes them. The union is large enough +// (32 string literals x 16 templates) to exercise the trie-based filtering in union +// reduction, including a string mapping constituent that is checked separately. The +// error message prints the reduced union: literals matched by a template or by the +// string mapping no longer appear. +declare let route: +>route : Symbol(route, Decl(templateLiteralUnionReduction.ts, 9, 11)) + + | `/app/${string}/billing` + | `/app/${string}/settings` + | `/app/${string}/integrations/${string}/billing` + | `/app/${string}/resources/${string}/billing` + | `/admin/${string}/billing` + | `/admin/${string}/settings` + | `/admin/${string}/users/${string}` + | `/app/${string}` + | `/app/${string}/billing/v2` + | `/app/${string}/settings/v2` + | `/admin/${string}/billing/v2` + | `/admin/${string}/settings/v2` + | `/org/${string}/billing` + | `/org/${string}/settings` + | `/org/${string}/members/${string}` + | `/org/${string}` + | Uppercase<`up${string}`> +>Uppercase : Symbol(Uppercase, Decl(lib.es5.d.ts, --, --)) + + // matched by one of the templates above + | "/app/acme/billing" + | "/app/acme/settings" + | "/app/acme/integrations/vercel/billing" + | "/app/acme/resources/aws/billing" + | "/admin/acme/billing" + | "/admin/acme/settings" + | "/admin/acme/users/bob" + | "/app/anything" + | "/app/acme/billing/v2" + | "/app/acme/settings/v2" + | "/admin/acme/billing/v2" + | "/admin/acme/settings/v2" + | "/org/acme/billing" + | "/org/acme/settings" + | "/org/acme/members/bob" + | "/org/acme" + // matched by the string mapping + | "UPGRADE" + // matched by nothing + | "/api/ai-playground/sandbox" + | "/api/users" + | "/about" + | "/pricing" + | "/admin/acme/invoices" + | "/admin/acme/settings/v3" + | "/admin/settings" + | "/app" + | "app/acme/billing" + | "/APP/ACME/BILLING" + | "/help" + | "/blog/post" + | "/login" + | "/logout" + | "/signup" + | "/status"; + +route = "/definitely/not/a/route"; +>route : Symbol(route, Decl(templateLiteralUnionReduction.ts, 9, 11)) + +// Small unions take the linear path; semantics are identical. +declare let small: +>small : Symbol(small, Decl(templateLiteralUnionReduction.ts, 67, 11)) + + | "up1" + | "other" + | `up${string}` + | Uppercase<`up${string}`>; +>Uppercase : Symbol(Uppercase, Decl(lib.es5.d.ts, --, --)) + +small = "not-a-member"; +>small : Symbol(small, Decl(templateLiteralUnionReduction.ts, 67, 11)) + +// "aaa" does not match `aa${string}aa`: the static prefix and suffix would have to +// overlap. +declare let overlap: "aaa" | "aaaa" | `aa${string}aa`; +>overlap : Symbol(overlap, Decl(templateLiteralUnionReduction.ts, 77, 11)) + +overlap = "aa"; +>overlap : Symbol(overlap, Decl(templateLiteralUnionReduction.ts, 77, 11)) + diff --git a/tsc/testdata/baselines/reference/compiler/templateLiteralUnionReduction.types b/tsc/testdata/baselines/reference/compiler/templateLiteralUnionReduction.types new file mode 100644 index 0000000000000..c15b0c4fa34ff --- /dev/null +++ b/tsc/testdata/baselines/reference/compiler/templateLiteralUnionReduction.types @@ -0,0 +1,98 @@ +//// [tests/cases/compiler/templateLiteralUnionReduction.ts] //// + +=== templateLiteralUnionReduction.ts === +// https://github.com/microsoft/TypeScript/issues/63342 + +// Route patterns with overlapping static prefixes, modeled after the dynamic routes in +// the linked issue. Several patterns share both their prefix and their number of +// segments, so only the final static text distinguishes them. The union is large enough +// (32 string literals x 16 templates) to exercise the trie-based filtering in union +// reduction, including a string mapping constituent that is checked separately. The +// error message prints the reduced union: literals matched by a template or by the +// string mapping no longer appear. +declare let route: +>route : "/APP/ACME/BILLING" | "/about" | "/admin/acme/invoices" | "/admin/acme/settings/v3" | "/admin/settings" | "/api/ai-playground/sandbox" | "/api/users" | "/app" | "/blog/post" | "/help" | "/login" | "/logout" | "/pricing" | "/signup" | "/status" | "app/acme/billing" | `/admin/${string}/billing` | `/admin/${string}/billing/v2` | `/admin/${string}/settings` | `/admin/${string}/settings/v2` | `/admin/${string}/users/${string}` | `/app/${string}` | `/app/${string}/billing` | `/app/${string}/billing/v2` | `/app/${string}/integrations/${string}/billing` | `/app/${string}/resources/${string}/billing` | `/app/${string}/settings` | `/app/${string}/settings/v2` | `/org/${string}` | `/org/${string}/billing` | `/org/${string}/members/${string}` | `/org/${string}/settings` | `UP${Uppercase}` + + | `/app/${string}/billing` + | `/app/${string}/settings` + | `/app/${string}/integrations/${string}/billing` + | `/app/${string}/resources/${string}/billing` + | `/admin/${string}/billing` + | `/admin/${string}/settings` + | `/admin/${string}/users/${string}` + | `/app/${string}` + | `/app/${string}/billing/v2` + | `/app/${string}/settings/v2` + | `/admin/${string}/billing/v2` + | `/admin/${string}/settings/v2` + | `/org/${string}/billing` + | `/org/${string}/settings` + | `/org/${string}/members/${string}` + | `/org/${string}` + | Uppercase<`up${string}`> + // matched by one of the templates above + | "/app/acme/billing" + | "/app/acme/settings" + | "/app/acme/integrations/vercel/billing" + | "/app/acme/resources/aws/billing" + | "/admin/acme/billing" + | "/admin/acme/settings" + | "/admin/acme/users/bob" + | "/app/anything" + | "/app/acme/billing/v2" + | "/app/acme/settings/v2" + | "/admin/acme/billing/v2" + | "/admin/acme/settings/v2" + | "/org/acme/billing" + | "/org/acme/settings" + | "/org/acme/members/bob" + | "/org/acme" + // matched by the string mapping + | "UPGRADE" + // matched by nothing + | "/api/ai-playground/sandbox" + | "/api/users" + | "/about" + | "/pricing" + | "/admin/acme/invoices" + | "/admin/acme/settings/v3" + | "/admin/settings" + | "/app" + | "app/acme/billing" + | "/APP/ACME/BILLING" + | "/help" + | "/blog/post" + | "/login" + | "/logout" + | "/signup" + | "/status"; + +route = "/definitely/not/a/route"; +>route = "/definitely/not/a/route" : "/definitely/not/a/route" +>route : "/APP/ACME/BILLING" | "/about" | "/admin/acme/invoices" | "/admin/acme/settings/v3" | "/admin/settings" | "/api/ai-playground/sandbox" | "/api/users" | "/app" | "/blog/post" | "/help" | "/login" | "/logout" | "/pricing" | "/signup" | "/status" | "app/acme/billing" | `/admin/${string}/billing` | `/admin/${string}/billing/v2` | `/admin/${string}/settings` | `/admin/${string}/settings/v2` | `/admin/${string}/users/${string}` | `/app/${string}` | `/app/${string}/billing` | `/app/${string}/billing/v2` | `/app/${string}/integrations/${string}/billing` | `/app/${string}/resources/${string}/billing` | `/app/${string}/settings` | `/app/${string}/settings/v2` | `/org/${string}` | `/org/${string}/billing` | `/org/${string}/members/${string}` | `/org/${string}/settings` | `UP${Uppercase}` +>"/definitely/not/a/route" : "/definitely/not/a/route" + +// Small unions take the linear path; semantics are identical. +declare let small: +>small : "other" | `UP${Uppercase}` | `up${string}` + + | "up1" + | "other" + | `up${string}` + | Uppercase<`up${string}`>; + +small = "not-a-member"; +>small = "not-a-member" : "not-a-member" +>small : "other" | `UP${Uppercase}` | `up${string}` +>"not-a-member" : "not-a-member" + +// "aaa" does not match `aa${string}aa`: the static prefix and suffix would have to +// overlap. +declare let overlap: "aaa" | "aaaa" | `aa${string}aa`; +>overlap : "aaa" | `aa${string}aa` + +overlap = "aa"; +>overlap = "aa" : "aa" +>overlap : "aaa" | `aa${string}aa` +>"aa" : "aa" + diff --git a/tsc/testdata/tests/cases/compiler/templateLiteralUnionReduction.ts b/tsc/testdata/tests/cases/compiler/templateLiteralUnionReduction.ts new file mode 100644 index 0000000000000..c5a7f55b90cd5 --- /dev/null +++ b/tsc/testdata/tests/cases/compiler/templateLiteralUnionReduction.ts @@ -0,0 +1,83 @@ +// @strict: true +// @noEmit: true +// @target: esnext +// https://github.com/microsoft/TypeScript/issues/63342 + +// Route patterns with overlapping static prefixes, modeled after the dynamic routes in +// the linked issue. Several patterns share both their prefix and their number of +// segments, so only the final static text distinguishes them. The union is large enough +// (32 string literals x 16 templates) to exercise the trie-based filtering in union +// reduction, including a string mapping constituent that is checked separately. The +// error message prints the reduced union: literals matched by a template or by the +// string mapping no longer appear. +declare let route: + | `/app/${string}/billing` + | `/app/${string}/settings` + | `/app/${string}/integrations/${string}/billing` + | `/app/${string}/resources/${string}/billing` + | `/admin/${string}/billing` + | `/admin/${string}/settings` + | `/admin/${string}/users/${string}` + | `/app/${string}` + | `/app/${string}/billing/v2` + | `/app/${string}/settings/v2` + | `/admin/${string}/billing/v2` + | `/admin/${string}/settings/v2` + | `/org/${string}/billing` + | `/org/${string}/settings` + | `/org/${string}/members/${string}` + | `/org/${string}` + | Uppercase<`up${string}`> + // matched by one of the templates above + | "/app/acme/billing" + | "/app/acme/settings" + | "/app/acme/integrations/vercel/billing" + | "/app/acme/resources/aws/billing" + | "/admin/acme/billing" + | "/admin/acme/settings" + | "/admin/acme/users/bob" + | "/app/anything" + | "/app/acme/billing/v2" + | "/app/acme/settings/v2" + | "/admin/acme/billing/v2" + | "/admin/acme/settings/v2" + | "/org/acme/billing" + | "/org/acme/settings" + | "/org/acme/members/bob" + | "/org/acme" + // matched by the string mapping + | "UPGRADE" + // matched by nothing + | "/api/ai-playground/sandbox" + | "/api/users" + | "/about" + | "/pricing" + | "/admin/acme/invoices" + | "/admin/acme/settings/v3" + | "/admin/settings" + | "/app" + | "app/acme/billing" + | "/APP/ACME/BILLING" + | "/help" + | "/blog/post" + | "/login" + | "/logout" + | "/signup" + | "/status"; + +route = "/definitely/not/a/route"; + +// Small unions take the linear path; semantics are identical. +declare let small: + | "up1" + | "other" + | `up${string}` + | Uppercase<`up${string}`>; + +small = "not-a-member"; + +// "aaa" does not match `aa${string}aa`: the static prefix and suffix would have to +// overlap. +declare let overlap: "aaa" | "aaaa" | `aa${string}aa`; + +overlap = "aa";