Skip to content

fix(checker): narrow Uppercase/Lowercase/Capitalize/Uncapitalize<string> via equality checks - #64223

Open
erantianantha wants to merge 1 commit into
microsoft:mainfrom
erantianantha:fix/string-mapping-narrowing
Open

fix(checker): narrow Uppercase/Lowercase/Capitalize/Uncapitalize<string> via equality checks#64223
erantianantha wants to merge 1 commit into
microsoft:mainfrom
erantianantha:fix/string-mapping-narrowing

Conversation

@erantianantha

Copy link
Copy Markdown
Contributor

Fixes #63724.

Problem

Uppercase<string> (and its siblings Lowercase, Capitalize, Uncapitalize) could not be narrowed by equality checks against specific string literals:

const prefix = "be".toUpperCase() as Uppercase<string>;
if (prefix === "BE" || prefix === "LU" || prefix === "NL") {
    countryCode = prefix; // ❌ Type 'Uppercase<string>' is not assignable to '"BE" | "LU" | "NL"'
}

Additionally, always-false comparisons were not flagged:

function fn(foo: Uppercase<string>) {
    if (foo === "ba") {} // ❌ No error, but "ba" can never be Uppercase<string>
}

Root Cause

Bug 1 — replacePrimitivesWithLiterals in flow.go: The narrowing pipeline calls filterType (correctly retaining Uppercase<string> as comparable to "BE"), then calls replacePrimitivesWithLiterals to substitute the mapping with the literal. That function guards on TypeFlagsString | TypeFlagsTemplateLiteral but not TypeFlagsStringMapping, so Uppercase<string> fell through the switch and was returned unchanged.

Bug 2 — Comparable relation in relater.go: When checking isTypeComparableTo(Uppercase<string>, "ba"), the code hit the source=StringMapping, target≠StringMapping branch, which fell through to the base-constraint (string). Since "ba" is assignable to string, the comparison was deemed comparable in both directions, suppressing the TS2367 diagnostic.

Fix

tsc/internal/checker/flow.goreplacePrimitivesWithLiterals:

  • Added TypeFlagsStringMapping to the outer guard.
  • Added a case t.flags&TypeFlagsStringMapping != 0 branch that uses isMemberOfStringMapping to filter the right-hand side: only string literals whose mapped form equals themselves are kept (e.g. Uppercase("BE") = "BE" ✓, Uppercase("be") = "BE" ≠ "be" ✗).

tsc/internal/checker/relater.go — structured-type comparable relation:

  • In the source=StringMapping, target=StringLiteral path within the comparable relation specifically, delegated to isMemberOfStringMapping instead of the base-constraint fallthrough.

Result

const prefix: Uppercase<string> = ...;
if (prefix === "BE" || prefix === "LU" || prefix === "NL") {
    countryCode = prefix; // ✅ narrowed to "BE" | "LU" | "NL"
}

function fn(foo: Uppercase<string>) {
    if (foo === "ba") {} // ✅ TS2367: types 'Uppercase<string>' and '"ba"' have no overlap
    if (foo === "BA") {} // ✅ no error
}

All four string-mapping intrinsics (Uppercase, Lowercase, Capitalize, Uncapitalize) benefit from the same fix.

…ng> via equality checks

Fixes microsoft#63724.

Two related bugs:

1. replacePrimitivesWithLiterals (flow.go) did not handle TypeFlagsStringMapping,
   so narrowing by equality (=== "BE") on Uppercase<string> left the type
   unchanged instead of narrowing to the literal. The outer guard now includes
   TypeFlagsStringMapping, and a new case replaces a StringMapping type with
   the subset of string literals from the right-hand side that satisfy
   isMemberOfStringMapping — i.e. those whose mapped form equals the literal
   itself (e.g. Uppercase("BE") = "BE", so "BE" passes; "be" does not).

2. In the comparable relation (relater.go), comparing StringMapping against a
   string literal fell through to the base-constraint (string) path, making
   every string literal appear comparable to Uppercase<string>. This suppressed
   the TS2367 'no overlap' warning that should fire for always-false comparisons
   such as (foo === "ba") when foo: Uppercase<string>. The fix intercepts this
   case in the structured-type relation loop and directly delegates to
   isMemberOfStringMapping, returning TernaryFalse when the literal is not a
   member of the mapping.

After these fixes:
  - Uppercase<string> narrows to "BE" after (x === "BE") ✓
  - Lowercase<string> narrows to "be" after (x === "be") ✓
  - Comparing Uppercase<string> === "ba" is a TS2367 error ✓
  - Union narrowing (prefix === "BE" || prefix === "LU" || prefix === "NL")
    narrows Uppercase<string> to "BE" | "LU" | "NL", allowing assignment ✓
  - Capitalize and Uncapitalize get the same treatment ✓

Add conformance test: stringMappingNarrowing.ts
Copilot AI balanced review requested due to automatic review settings September 9, 2026 20:54
@github-project-automation github-project-automation Bot moved this to Not started in PR Backlog Sep 9, 2026
@typescript-automation typescript-automation Bot added For Backlog Bug PRs that fix a backlog bug labels Sep 9, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Generic mappings and mixed-union comparisons can be incorrectly rejected or narrowed.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Fixes string-mapping equality narrowing and impossible-comparison diagnostics.

Changes:

  • Extends narrowing for intrinsic string mappings.
  • Tightens literal comparability.
  • Adds conformance coverage and baselines.
File summaries
File Description
tsc/internal/checker/flow.go Narrows string mappings to literals.
tsc/internal/checker/relater.go Checks mapping/literal comparability.
tsc/testdata/tests/cases/conformance/types/literal/stringMappingNarrowing.ts Adds conformance cases.
tsc/testdata/baselines/reference/conformance/stringMappingNarrowing.types Records inferred types.
tsc/testdata/baselines/reference/conformance/stringMappingNarrowing.symbols Records symbols.
tsc/testdata/baselines/reference/conformance/stringMappingNarrowing.js Records emitted output.
tsc/testdata/baselines/reference/conformance/stringMappingNarrowing.errors.txt Records expected diagnostics.
Review details

Suppressed comments (1)

tsc/internal/checker/flow.go:1925

  • This predicate does not compute overlap per union constituent. It drops valid literals for generic mappings (for example "A" versus Uppercase<T>), while the unconditional fallback retains every template/string-mapping constituent, so narrowing x: Uppercase<string> against Uppercase<string> | Lowercase<string> can actually broaden x. Filter every candidate with the comparable relation instead; that preserves only constituents that can overlap with this mapping.
				return c.filterType(typeWithLiterals, func(lit *Type) bool {
					if lit.flags&TypeFlagsStringLiteral != 0 {
						return c.isMemberOfStringMapping(lit, t)
					}
  • Files reviewed: 7/7 changed files
  • Comments generated: 3
  • Review effort level: Balanced

switch {
case t.flags&TypeFlagsString != 0:
return c.extractTypesOfKind(typeWithLiterals, TypeFlagsString|TypeFlagsStringLiteral|TypeFlagsTemplateLiteral|TypeFlagsStringMapping)
case c.isPatternLiteralType(t) && !c.maybeTypeOfKind(typeWithLiterals, TypeFlagsString|TypeFlagsTemplateLiteral|TypeFlagsStringMapping):
Comment on lines +3829 to +3834
if r.relation == r.c.comparableRelation && target.flags&TypeFlagsStringLiteral != 0 {
if r.c.isMemberOfStringMapping(target, source) {
return TernaryTrue
}
return TernaryFalse
}

// === Basic narrowing: Uppercase<string> === literal ===

function testUppercaseNarrowing(x: Uppercase<string>) {
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

For Backlog Bug PRs that fix a backlog bug

Projects

Status: Not started

Development

Successfully merging this pull request may close these issues.

Type narrowing not working correctly with Uppercase<string> & Lowercase<string>

2 participants