diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index 2a4ead5003a89..a427b3d12a531 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -25977,28 +25977,82 @@ 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 { - 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 && 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 { + 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) +// 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 { + 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 + } + } + if len(stringMappings) > 0 { + if core.Some(stringMappings, func(sm *Type) bool { + return c.isMemberOfStringMapping(source, sm) + }) { + return true + } } - return c.isMemberOfStringMapping(t, template) + 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..d9fd3aa2a8e55 100644 --- a/tsc/internal/checker/relater.go +++ b/tsc/internal/checker/relater.go @@ -1105,6 +1105,125 @@ func (c *Checker) getMatchingUnionConstituentForType(unionType *Type, t *Type) * return c.getConstituentTypeForKeyType(unionType, propType) } +func (c *Checker) buildTemplateLiteralTrieFromTypes(templateTypes []*Type) *templateLiteralTrieNode { + var arena core.Arena[templateLiteralTrieNode] + var suffixArena core.Arena[templateLiteralSuffixTrieNode] + root := arena.New() + for _, t := range templateTypes { + texts := t.AsTemplateLiteralType().texts + node := root + 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]) + } + 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 `${number}`) + if t := c.findMatchingTemplateLiteralBySuffix(node.bySuffix, value, 0, source, compareTypes); t != nil { + return t + } + for i := range len(value) { + node = node.findChild(value[i]) + if node == nil { + return nil + } + 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 + } + 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 +} + // 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/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 144124e63c598..9974ada897490 100644 --- a/tsc/internal/checker/types.go +++ b/tsc/internal/checker/types.go @@ -1218,6 +1218,31 @@ type TemplateLiteralType struct { func (t *TemplateLiteralType) Texts() []string { return t.texts } func (t *TemplateLiteralType) Types() []*Type { return t.types } +type templateLiteralTrieNode struct { + // 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 { ConstrainedType target *Type 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";