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

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.go — replacePrimitivesWithLiterals:

  • 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>) {

This branch has not been deployed

No deployments
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