From fdcfbbccff5216b93b20565dbd8efb43aedddfb7 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:14:51 -0700 Subject: [PATCH 1/2] Speed up narrowing of literal unions Narrowing a union by a type predicate maps every source constituent over every candidate constituent through the general type relation machinery. Large generated literal unions therefore take quadratic time even though matching literals only requires equality. Intersect unions made entirely of non-enum literals using keyed sets. Keep enum and mixed unions on the existing relation path because they require broader assignability semantics. Also bypass relation work for individual identical literals encountered by that fallback path. Add coverage for narrowing mixed string and number literal unions, including equal-valued literals of different primitive kinds. Fixes #55948 --- tsc/internal/checker/flow.go | 35 +++++++++++++++++++ .../narrowLiteralUnionByTypePredicate.js | 19 ++++++++++ .../narrowLiteralUnionByTypePredicate.symbols | 28 +++++++++++++++ .../narrowLiteralUnionByTypePredicate.types | 25 +++++++++++++ .../narrowLiteralUnionByTypePredicate.ts | 11 ++++++ 5 files changed, 118 insertions(+) create mode 100644 tsc/testdata/baselines/reference/compiler/narrowLiteralUnionByTypePredicate.js create mode 100644 tsc/testdata/baselines/reference/compiler/narrowLiteralUnionByTypePredicate.symbols create mode 100644 tsc/testdata/baselines/reference/compiler/narrowLiteralUnionByTypePredicate.types create mode 100644 tsc/testdata/tests/cases/compiler/narrowLiteralUnionByTypePredicate.ts diff --git a/tsc/internal/checker/flow.go b/tsc/internal/checker/flow.go index d08254cc8ef88..4e9b6de80131e 100644 --- a/tsc/internal/checker/flow.go +++ b/tsc/internal/checker/flow.go @@ -21,6 +21,11 @@ type FlowType struct { incomplete bool } +type literalTypeKey struct { + flags TypeFlags + value any +} + func (ft *FlowType) isNil() bool { return ft.t == nil } @@ -880,6 +885,11 @@ func (c *Checker) getNarrowedTypeWorker(t *Type, candidate *Type, assumeTrue boo if t == candidate { return candidate } + if !checkDerived { + if narrowedType := c.tryNarrowLiteralUnion(t, candidate); narrowedType != nil { + return narrowedType + } + } // We first attempt to filter the current type, narrowing constituents as appropriate and removing // constituents that are unrelated to the candidate. var keyPropertyName string @@ -913,6 +923,14 @@ func (c *Checker) getNarrowedTypeWorker(t *Type, candidate *Type, assumeTrue boo } } else { mapType = func(t *Type) *Type { + tLiteralFlags := t.flags & TypeFlagsLiteral + nLiteralFlags := n.flags & TypeFlagsLiteral + if tLiteralFlags != 0 && tLiteralFlags == nLiteralFlags && !(t.flags&TypeFlagsEnumLiteral != 0 && n.flags&TypeFlagsEnumLiteral != 0) { + if t.AsLiteralType().value == n.AsLiteralType().value { + return t + } + return c.neverType + } switch { case c.isTypeStrictSubtypeOf(t, n): return t @@ -963,6 +981,23 @@ func (c *Checker) getNarrowedTypeWorker(t *Type, candidate *Type, assumeTrue boo return c.getIntersectionType([]*Type{t, candidate}) } +func (c *Checker) tryNarrowLiteralUnion(t *Type, candidate *Type) *Type { + isNonEnumLiteral := func(t *Type) bool { + return t.flags&TypeFlagsLiteral != 0 && t.flags&TypeFlagsEnumLiteral == 0 + } + if !everyType(t, isNonEnumLiteral) || !everyType(candidate, isNonEnumLiteral) { + return nil + } + candidateTypes := make(map[literalTypeKey]struct{}) + forEachType(candidate, func(t *Type) { + candidateTypes[literalTypeKey{flags: t.flags & TypeFlagsLiteral, value: t.AsLiteralType().value}] = struct{}{} + }) + return c.filterType(t, func(t *Type) bool { + _, ok := candidateTypes[literalTypeKey{flags: t.flags & TypeFlagsLiteral, value: t.AsLiteralType().value}] + return ok + }) +} + func (c *Checker) getInstanceType(constructorType *Type) *Type { prototypePropertyType := c.getTypeOfPropertyOfType(constructorType, "prototype") if prototypePropertyType != nil && !IsTypeAny(prototypePropertyType) { diff --git a/tsc/testdata/baselines/reference/compiler/narrowLiteralUnionByTypePredicate.js b/tsc/testdata/baselines/reference/compiler/narrowLiteralUnionByTypePredicate.js new file mode 100644 index 0000000000000..bf2fdf723a306 --- /dev/null +++ b/tsc/testdata/baselines/reference/compiler/narrowLiteralUnionByTypePredicate.js @@ -0,0 +1,19 @@ +//// [tests/cases/compiler/narrowLiteralUnionByTypePredicate.ts] //// + +//// [narrowLiteralUnionByTypePredicate.ts] +type Source = "a" | "b" | "c" | "d" | 1 | 2 | 3 | 4; +type Candidate = "b" | "d" | 1 | 3; + +declare const source: Source; +declare function isCandidate(value: Source): value is Candidate; + +if (isCandidate(source)) { + source; +} + + +//// [narrowLiteralUnionByTypePredicate.js] +"use strict"; +if (isCandidate(source)) { + source; +} diff --git a/tsc/testdata/baselines/reference/compiler/narrowLiteralUnionByTypePredicate.symbols b/tsc/testdata/baselines/reference/compiler/narrowLiteralUnionByTypePredicate.symbols new file mode 100644 index 0000000000000..ed5a4d80fa87c --- /dev/null +++ b/tsc/testdata/baselines/reference/compiler/narrowLiteralUnionByTypePredicate.symbols @@ -0,0 +1,28 @@ +//// [tests/cases/compiler/narrowLiteralUnionByTypePredicate.ts] //// + +=== narrowLiteralUnionByTypePredicate.ts === +type Source = "a" | "b" | "c" | "d" | 1 | 2 | 3 | 4; +>Source : Symbol(Source, Decl(narrowLiteralUnionByTypePredicate.ts, 0, 0)) + +type Candidate = "b" | "d" | 1 | 3; +>Candidate : Symbol(Candidate, Decl(narrowLiteralUnionByTypePredicate.ts, 0, 52)) + +declare const source: Source; +>source : Symbol(source, Decl(narrowLiteralUnionByTypePredicate.ts, 3, 13)) +>Source : Symbol(Source, Decl(narrowLiteralUnionByTypePredicate.ts, 0, 0)) + +declare function isCandidate(value: Source): value is Candidate; +>isCandidate : Symbol(isCandidate, Decl(narrowLiteralUnionByTypePredicate.ts, 3, 29)) +>value : Symbol(value, Decl(narrowLiteralUnionByTypePredicate.ts, 4, 29)) +>Source : Symbol(Source, Decl(narrowLiteralUnionByTypePredicate.ts, 0, 0)) +>value : Symbol(value, Decl(narrowLiteralUnionByTypePredicate.ts, 4, 29)) +>Candidate : Symbol(Candidate, Decl(narrowLiteralUnionByTypePredicate.ts, 0, 52)) + +if (isCandidate(source)) { +>isCandidate : Symbol(isCandidate, Decl(narrowLiteralUnionByTypePredicate.ts, 3, 29)) +>source : Symbol(source, Decl(narrowLiteralUnionByTypePredicate.ts, 3, 13)) + + source; +>source : Symbol(source, Decl(narrowLiteralUnionByTypePredicate.ts, 3, 13)) +} + diff --git a/tsc/testdata/baselines/reference/compiler/narrowLiteralUnionByTypePredicate.types b/tsc/testdata/baselines/reference/compiler/narrowLiteralUnionByTypePredicate.types new file mode 100644 index 0000000000000..8d977fe04ef8b --- /dev/null +++ b/tsc/testdata/baselines/reference/compiler/narrowLiteralUnionByTypePredicate.types @@ -0,0 +1,25 @@ +//// [tests/cases/compiler/narrowLiteralUnionByTypePredicate.ts] //// + +=== narrowLiteralUnionByTypePredicate.ts === +type Source = "a" | "b" | "c" | "d" | 1 | 2 | 3 | 4; +>Source : Source + +type Candidate = "b" | "d" | 1 | 3; +>Candidate : Candidate + +declare const source: Source; +>source : Source + +declare function isCandidate(value: Source): value is Candidate; +>isCandidate : (value: Source) => value is Candidate +>value : Source + +if (isCandidate(source)) { +>isCandidate(source) : boolean +>isCandidate : (value: Source) => value is Candidate +>source : Source + + source; +>source : "b" | "d" | 1 | 3 +} + diff --git a/tsc/testdata/tests/cases/compiler/narrowLiteralUnionByTypePredicate.ts b/tsc/testdata/tests/cases/compiler/narrowLiteralUnionByTypePredicate.ts new file mode 100644 index 0000000000000..c8db6df66f967 --- /dev/null +++ b/tsc/testdata/tests/cases/compiler/narrowLiteralUnionByTypePredicate.ts @@ -0,0 +1,11 @@ +// @strict: true + +type Source = "a" | "b" | "c" | "d" | 1 | 2 | 3 | 4; +type Candidate = "b" | "d" | 1 | 3; + +declare const source: Source; +declare function isCandidate(value: Source): value is Candidate; + +if (isCandidate(source)) { + source; +} From 97fdd0d6b29248299f449e79c00aa6c49d17f94e Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:30:10 -0700 Subject: [PATCH 2/2] Preserve enum identity in literal narrowing The per-literal fast path compared values and retained the source constituent. When a plain string literal was narrowed by a predicate whose candidate was the matching enum member, this lost the enum type and made the result unassignable to that string enum member. Retain the candidate when it carries enum identity and the source does not. Continue using the existing relation machinery when both sides are enum members, where equal underlying values are not sufficient. Add a string-enum predicate regression that requires the narrowed value to remain assignable to the asserted enum member. --- tsc/internal/checker/flow.go | 3 ++ .../narrowLiteralUnionByTypePredicate.js | 20 ++++++++++++ .../narrowLiteralUnionByTypePredicate.symbols | 31 +++++++++++++++++++ .../narrowLiteralUnionByTypePredicate.types | 31 +++++++++++++++++++ .../narrowLiteralUnionByTypePredicate.ts | 12 +++++++ 5 files changed, 97 insertions(+) diff --git a/tsc/internal/checker/flow.go b/tsc/internal/checker/flow.go index 4e9b6de80131e..e5d6e2fb09ec4 100644 --- a/tsc/internal/checker/flow.go +++ b/tsc/internal/checker/flow.go @@ -927,6 +927,9 @@ func (c *Checker) getNarrowedTypeWorker(t *Type, candidate *Type, assumeTrue boo nLiteralFlags := n.flags & TypeFlagsLiteral if tLiteralFlags != 0 && tLiteralFlags == nLiteralFlags && !(t.flags&TypeFlagsEnumLiteral != 0 && n.flags&TypeFlagsEnumLiteral != 0) { if t.AsLiteralType().value == n.AsLiteralType().value { + if n.flags&TypeFlagsEnumLiteral != 0 { + return n + } return t } return c.neverType diff --git a/tsc/testdata/baselines/reference/compiler/narrowLiteralUnionByTypePredicate.js b/tsc/testdata/baselines/reference/compiler/narrowLiteralUnionByTypePredicate.js index bf2fdf723a306..43ae6d32f0bd3 100644 --- a/tsc/testdata/baselines/reference/compiler/narrowLiteralUnionByTypePredicate.js +++ b/tsc/testdata/baselines/reference/compiler/narrowLiteralUnionByTypePredicate.js @@ -10,6 +10,18 @@ declare function isCandidate(value: Source): value is Candidate; if (isCandidate(source)) { source; } + +enum E { + A = "a", + B = "b", +} + +declare const enumSource: "a" | "b"; +declare function isEnumA(value: "a" | "b"): value is E.A; + +if (isEnumA(enumSource)) { + const enumA: E.A = enumSource; +} //// [narrowLiteralUnionByTypePredicate.js] @@ -17,3 +29,11 @@ if (isCandidate(source)) { if (isCandidate(source)) { source; } +var E; +(function (E) { + E["A"] = "a"; + E["B"] = "b"; +})(E || (E = {})); +if (isEnumA(enumSource)) { + const enumA = enumSource; +} diff --git a/tsc/testdata/baselines/reference/compiler/narrowLiteralUnionByTypePredicate.symbols b/tsc/testdata/baselines/reference/compiler/narrowLiteralUnionByTypePredicate.symbols index ed5a4d80fa87c..570ee92c47fbb 100644 --- a/tsc/testdata/baselines/reference/compiler/narrowLiteralUnionByTypePredicate.symbols +++ b/tsc/testdata/baselines/reference/compiler/narrowLiteralUnionByTypePredicate.symbols @@ -26,3 +26,34 @@ if (isCandidate(source)) { >source : Symbol(source, Decl(narrowLiteralUnionByTypePredicate.ts, 3, 13)) } +enum E { +>E : Symbol(E, Decl(narrowLiteralUnionByTypePredicate.ts, 8, 1)) + + A = "a", +>A : Symbol(E.A, Decl(narrowLiteralUnionByTypePredicate.ts, 10, 8)) + + B = "b", +>B : Symbol(E.B, Decl(narrowLiteralUnionByTypePredicate.ts, 11, 12)) +} + +declare const enumSource: "a" | "b"; +>enumSource : Symbol(enumSource, Decl(narrowLiteralUnionByTypePredicate.ts, 15, 13)) + +declare function isEnumA(value: "a" | "b"): value is E.A; +>isEnumA : Symbol(isEnumA, Decl(narrowLiteralUnionByTypePredicate.ts, 15, 36)) +>value : Symbol(value, Decl(narrowLiteralUnionByTypePredicate.ts, 16, 25)) +>value : Symbol(value, Decl(narrowLiteralUnionByTypePredicate.ts, 16, 25)) +>E : Symbol(E, Decl(narrowLiteralUnionByTypePredicate.ts, 8, 1)) +>A : Symbol(E.A, Decl(narrowLiteralUnionByTypePredicate.ts, 10, 8)) + +if (isEnumA(enumSource)) { +>isEnumA : Symbol(isEnumA, Decl(narrowLiteralUnionByTypePredicate.ts, 15, 36)) +>enumSource : Symbol(enumSource, Decl(narrowLiteralUnionByTypePredicate.ts, 15, 13)) + + const enumA: E.A = enumSource; +>enumA : Symbol(enumA, Decl(narrowLiteralUnionByTypePredicate.ts, 19, 9)) +>E : Symbol(E, Decl(narrowLiteralUnionByTypePredicate.ts, 8, 1)) +>A : Symbol(E.A, Decl(narrowLiteralUnionByTypePredicate.ts, 10, 8)) +>enumSource : Symbol(enumSource, Decl(narrowLiteralUnionByTypePredicate.ts, 15, 13)) +} + diff --git a/tsc/testdata/baselines/reference/compiler/narrowLiteralUnionByTypePredicate.types b/tsc/testdata/baselines/reference/compiler/narrowLiteralUnionByTypePredicate.types index 8d977fe04ef8b..f8b7188f14618 100644 --- a/tsc/testdata/baselines/reference/compiler/narrowLiteralUnionByTypePredicate.types +++ b/tsc/testdata/baselines/reference/compiler/narrowLiteralUnionByTypePredicate.types @@ -23,3 +23,34 @@ if (isCandidate(source)) { >source : "b" | "d" | 1 | 3 } +enum E { +>E : E + + A = "a", +>A : E.A +>"a" : "a" + + B = "b", +>B : E.B +>"b" : "b" +} + +declare const enumSource: "a" | "b"; +>enumSource : "a" | "b" + +declare function isEnumA(value: "a" | "b"): value is E.A; +>isEnumA : (value: "a" | "b") => value is E.A +>value : "a" | "b" +>E : any + +if (isEnumA(enumSource)) { +>isEnumA(enumSource) : boolean +>isEnumA : (value: "a" | "b") => value is E.A +>enumSource : "a" | "b" + + const enumA: E.A = enumSource; +>enumA : E.A +>E : any +>enumSource : E.A +} + diff --git a/tsc/testdata/tests/cases/compiler/narrowLiteralUnionByTypePredicate.ts b/tsc/testdata/tests/cases/compiler/narrowLiteralUnionByTypePredicate.ts index c8db6df66f967..3bdd49ee38b52 100644 --- a/tsc/testdata/tests/cases/compiler/narrowLiteralUnionByTypePredicate.ts +++ b/tsc/testdata/tests/cases/compiler/narrowLiteralUnionByTypePredicate.ts @@ -9,3 +9,15 @@ declare function isCandidate(value: Source): value is Candidate; if (isCandidate(source)) { source; } + +enum E { + A = "a", + B = "b", +} + +declare const enumSource: "a" | "b"; +declare function isEnumA(value: "a" | "b"): value is E.A; + +if (isEnumA(enumSource)) { + const enumA: E.A = enumSource; +}