From 96ca78b2072235e655a57448e25da7f78523f87f Mon Sep 17 00:00:00 2001 From: DukeDeSouth Date: Wed, 17 Jun 2026 00:17:44 -0400 Subject: [PATCH] Narrow destructured discriminant whose binding has a default A default on a required discriminant binding no longer disables discriminant narrowing of the sibling bindings. The default is ignored for narrowing only when it can never apply (the property is present and non-undefined in every union constituent), so optional or possibly-undefined discriminants keep their previous sound behavior. Fixes #50139 --- src/compiler/checker.ts | 20 +- ...structuredVariablesWithDefaults.errors.txt | 117 ++++++ ...tDestructuredVariablesWithDefaults.symbols | 238 +++++++++++ ...entDestructuredVariablesWithDefaults.types | 383 ++++++++++++++++++ ...endentDestructuredVariablesWithDefaults.ts | 101 +++++ 5 files changed, 858 insertions(+), 1 deletion(-) create mode 100644 tests/baselines/reference/dependentDestructuredVariablesWithDefaults.errors.txt create mode 100644 tests/baselines/reference/dependentDestructuredVariablesWithDefaults.symbols create mode 100644 tests/baselines/reference/dependentDestructuredVariablesWithDefaults.types create mode 100644 tests/cases/conformance/controlFlow/dependentDestructuredVariablesWithDefaults.ts diff --git a/src/compiler/checker.ts b/src/compiler/checker.ts index 0567712f11da3..903c4bd140b06 100644 --- a/src/compiler/checker.ts +++ b/src/compiler/checker.ts @@ -29475,7 +29475,10 @@ export function createTypeChecker(host: TypeCheckerHost): TypeChecker { if (isIdentifier(expr)) { const symbol = getResolvedSymbol(expr); const declaration = getExportSymbolOfValueSymbolIfExported(symbol).valueDeclaration; - if (declaration && (isBindingElement(declaration) || isParameter(declaration)) && reference === declaration.parent && !declaration.initializer && !declaration.dotDotDotToken) { + if ( + declaration && (isBindingElement(declaration) || isParameter(declaration)) && reference === declaration.parent && !declaration.dotDotDotToken && + (!declaration.initializer || isBindingElement(declaration) && bindingElementDefaultNeverApplies(declaration)) + ) { return declaration; } } @@ -29509,6 +29512,21 @@ export function createTypeChecker(host: TypeCheckerHost): TypeChecker { function getCandidateVariableDeclarationInitializer(node: Node) { return isVariableDeclaration(node) && !node.type && node.initializer ? skipParentheses(node.initializer) : undefined; } + + function bindingElementDefaultNeverApplies(declaration: BindingElement) { + // A binding element with a default initializer (e.g. `{ kind = "a" }`) only faithfully mirrors its + // source property for discriminant narrowing when the default can never substitute for a real value, + // i.e. the property is present and non-undefined in every constituent of the declared union. For an + // optional or possibly-undefined property the default stands in for `undefined`, so narrowing the + // parent by this binding would be unsound. + const name = getAccessedPropertyName(declaration); + if (name === undefined) { + return false; + } + const prop = getPropertyOfType(declaredType, name); + return !!prop && !(prop.flags & SymbolFlags.Optional) && !(getCheckFlags(prop) & CheckFlags.Partial) && + !maybeTypeOfKind(getTypeOfSymbol(prop), TypeFlags.Undefined); + } } function getDiscriminantPropertyAccess(expr: Expression, computedType: Type) { diff --git a/tests/baselines/reference/dependentDestructuredVariablesWithDefaults.errors.txt b/tests/baselines/reference/dependentDestructuredVariablesWithDefaults.errors.txt new file mode 100644 index 0000000000000..83eb6353396c0 --- /dev/null +++ b/tests/baselines/reference/dependentDestructuredVariablesWithDefaults.errors.txt @@ -0,0 +1,117 @@ +dependentDestructuredVariablesWithDefaults.ts(74,15): error TS2322: Type 'string | number' is not assignable to type 'string'. + Type 'number' is not assignable to type 'string'. +dependentDestructuredVariablesWithDefaults.ts(85,15): error TS2322: Type 'string | number' is not assignable to type 'string'. + Type 'number' is not assignable to type 'string'. +dependentDestructuredVariablesWithDefaults.ts(96,15): error TS2322: Type 'string | number' is not assignable to type 'string'. + Type 'number' is not assignable to type 'string'. + + +==== dependentDestructuredVariablesWithDefaults.ts (3 errors) ==== + // https://github.com/microsoft/TypeScript/issues/50139 + // A default on a *required* discriminant must not disable discriminant narrowing of siblings. + + type Props = + | { isText: true, children: string } + | { isText: false, children: number }; + + // Baseline: no default — narrows (already worked). + function noDefault({ isText, children }: Props) { + if (isText === true) { + const s: string = children; + } else { + const n: number = children; + } + } + + // The bug: default on a required discriminant should still narrow siblings. + function withDefault({ isText = false, children }: Props) { + if (isText === true) { + const s: string = children; + } else { + const n: number = children; + } + } + + // switch form. + function withDefaultSwitch({ isText = false, children }: Props) { + switch (isText) { + case true: { const s: string = children; break; } + case false: { const n: number = children; break; } + } + } + + // Renamed binding `{ isText: t = false }`. + function renamed({ isText: t = false, children }: Props) { + if (t === true) { + const s: string = children; + } else { + const n: number = children; + } + } + + // Three-way union with a string discriminant. + type Three = + | { t: "i", v: number } + | { t: "s", v: string } + | { t: "b", v: boolean }; + + function threeWay({ t = "i", v }: Three) { + if (t === "s") { + const s: string = v; + } + } + + // const destructuring (not a parameter). + declare const props: Props; + function constDestructure() { + const { isText = false, children } = props; + if (isText === true) { + const s: string = children; + } + } + + // --- Soundness boundary: these must still error --- + + // Optional discriminant + default: calling with the property omitted yields the default, + // so the sibling cannot be safely narrowed. + type OptDisc = + | { kind?: "a", x: string } + | { kind?: "b", x: number }; + + function optionalDiscriminant({ kind = "a", x }: OptDisc) { + if (kind === "a") { + const s: string = x; // error + ~ +!!! error TS2322: Type 'string | number' is not assignable to type 'string'. +!!! error TS2322: Type 'number' is not assignable to type 'string'. + } + } + + // Discriminant whose type already includes undefined. + type UndefDisc = + | { kind: "a" | undefined, x: string } + | { kind: "b", x: number }; + + function undefinedDiscriminant({ kind = "a", x }: UndefDisc) { + if (kind === "a") { + const s: string = x; // error + ~ +!!! error TS2322: Type 'string | number' is not assignable to type 'string'. +!!! error TS2322: Type 'number' is not assignable to type 'string'. + } + } + + // Sibling has its OWN default: it must not be narrowed from the parent. + type PropsOpt = + | { isText: true, children?: string } + | { isText: false, children?: number }; + + function siblingDefault({ isText = false, children = 0 }: PropsOpt) { + if (isText === true) { + const s: string = children; // error + ~ +!!! error TS2322: Type 'string | number' is not assignable to type 'string'. +!!! error TS2322: Type 'number' is not assignable to type 'string'. + } + } + \ No newline at end of file diff --git a/tests/baselines/reference/dependentDestructuredVariablesWithDefaults.symbols b/tests/baselines/reference/dependentDestructuredVariablesWithDefaults.symbols new file mode 100644 index 0000000000000..79efa510d406c --- /dev/null +++ b/tests/baselines/reference/dependentDestructuredVariablesWithDefaults.symbols @@ -0,0 +1,238 @@ +//// [tests/cases/conformance/controlFlow/dependentDestructuredVariablesWithDefaults.ts] //// + +=== dependentDestructuredVariablesWithDefaults.ts === +// https://github.com/microsoft/TypeScript/issues/50139 +// A default on a *required* discriminant must not disable discriminant narrowing of siblings. + +type Props = +>Props : Symbol(Props, Decl(dependentDestructuredVariablesWithDefaults.ts, 0, 0)) + + | { isText: true, children: string } +>isText : Symbol(isText, Decl(dependentDestructuredVariablesWithDefaults.ts, 4, 7)) +>children : Symbol(children, Decl(dependentDestructuredVariablesWithDefaults.ts, 4, 21)) + + | { isText: false, children: number }; +>isText : Symbol(isText, Decl(dependentDestructuredVariablesWithDefaults.ts, 5, 7)) +>children : Symbol(children, Decl(dependentDestructuredVariablesWithDefaults.ts, 5, 22)) + +// Baseline: no default — narrows (already worked). +function noDefault({ isText, children }: Props) { +>noDefault : Symbol(noDefault, Decl(dependentDestructuredVariablesWithDefaults.ts, 5, 42)) +>isText : Symbol(isText, Decl(dependentDestructuredVariablesWithDefaults.ts, 8, 20)) +>children : Symbol(children, Decl(dependentDestructuredVariablesWithDefaults.ts, 8, 28)) +>Props : Symbol(Props, Decl(dependentDestructuredVariablesWithDefaults.ts, 0, 0)) + + if (isText === true) { +>isText : Symbol(isText, Decl(dependentDestructuredVariablesWithDefaults.ts, 8, 20)) + + const s: string = children; +>s : Symbol(s, Decl(dependentDestructuredVariablesWithDefaults.ts, 10, 13)) +>children : Symbol(children, Decl(dependentDestructuredVariablesWithDefaults.ts, 8, 28)) + + } else { + const n: number = children; +>n : Symbol(n, Decl(dependentDestructuredVariablesWithDefaults.ts, 12, 13)) +>children : Symbol(children, Decl(dependentDestructuredVariablesWithDefaults.ts, 8, 28)) + } +} + +// The bug: default on a required discriminant should still narrow siblings. +function withDefault({ isText = false, children }: Props) { +>withDefault : Symbol(withDefault, Decl(dependentDestructuredVariablesWithDefaults.ts, 14, 1)) +>isText : Symbol(isText, Decl(dependentDestructuredVariablesWithDefaults.ts, 17, 22)) +>children : Symbol(children, Decl(dependentDestructuredVariablesWithDefaults.ts, 17, 38)) +>Props : Symbol(Props, Decl(dependentDestructuredVariablesWithDefaults.ts, 0, 0)) + + if (isText === true) { +>isText : Symbol(isText, Decl(dependentDestructuredVariablesWithDefaults.ts, 17, 22)) + + const s: string = children; +>s : Symbol(s, Decl(dependentDestructuredVariablesWithDefaults.ts, 19, 13)) +>children : Symbol(children, Decl(dependentDestructuredVariablesWithDefaults.ts, 17, 38)) + + } else { + const n: number = children; +>n : Symbol(n, Decl(dependentDestructuredVariablesWithDefaults.ts, 21, 13)) +>children : Symbol(children, Decl(dependentDestructuredVariablesWithDefaults.ts, 17, 38)) + } +} + +// switch form. +function withDefaultSwitch({ isText = false, children }: Props) { +>withDefaultSwitch : Symbol(withDefaultSwitch, Decl(dependentDestructuredVariablesWithDefaults.ts, 23, 1)) +>isText : Symbol(isText, Decl(dependentDestructuredVariablesWithDefaults.ts, 26, 28)) +>children : Symbol(children, Decl(dependentDestructuredVariablesWithDefaults.ts, 26, 44)) +>Props : Symbol(Props, Decl(dependentDestructuredVariablesWithDefaults.ts, 0, 0)) + + switch (isText) { +>isText : Symbol(isText, Decl(dependentDestructuredVariablesWithDefaults.ts, 26, 28)) + + case true: { const s: string = children; break; } +>s : Symbol(s, Decl(dependentDestructuredVariablesWithDefaults.ts, 28, 26)) +>children : Symbol(children, Decl(dependentDestructuredVariablesWithDefaults.ts, 26, 44)) + + case false: { const n: number = children; break; } +>n : Symbol(n, Decl(dependentDestructuredVariablesWithDefaults.ts, 29, 27)) +>children : Symbol(children, Decl(dependentDestructuredVariablesWithDefaults.ts, 26, 44)) + } +} + +// Renamed binding `{ isText: t = false }`. +function renamed({ isText: t = false, children }: Props) { +>renamed : Symbol(renamed, Decl(dependentDestructuredVariablesWithDefaults.ts, 31, 1)) +>isText : Symbol(isText, Decl(dependentDestructuredVariablesWithDefaults.ts, 4, 7), Decl(dependentDestructuredVariablesWithDefaults.ts, 5, 7)) +>t : Symbol(t, Decl(dependentDestructuredVariablesWithDefaults.ts, 34, 18)) +>children : Symbol(children, Decl(dependentDestructuredVariablesWithDefaults.ts, 34, 37)) +>Props : Symbol(Props, Decl(dependentDestructuredVariablesWithDefaults.ts, 0, 0)) + + if (t === true) { +>t : Symbol(t, Decl(dependentDestructuredVariablesWithDefaults.ts, 34, 18)) + + const s: string = children; +>s : Symbol(s, Decl(dependentDestructuredVariablesWithDefaults.ts, 36, 13)) +>children : Symbol(children, Decl(dependentDestructuredVariablesWithDefaults.ts, 34, 37)) + + } else { + const n: number = children; +>n : Symbol(n, Decl(dependentDestructuredVariablesWithDefaults.ts, 38, 13)) +>children : Symbol(children, Decl(dependentDestructuredVariablesWithDefaults.ts, 34, 37)) + } +} + +// Three-way union with a string discriminant. +type Three = +>Three : Symbol(Three, Decl(dependentDestructuredVariablesWithDefaults.ts, 40, 1)) + + | { t: "i", v: number } +>t : Symbol(t, Decl(dependentDestructuredVariablesWithDefaults.ts, 44, 7)) +>v : Symbol(v, Decl(dependentDestructuredVariablesWithDefaults.ts, 44, 15)) + + | { t: "s", v: string } +>t : Symbol(t, Decl(dependentDestructuredVariablesWithDefaults.ts, 45, 7)) +>v : Symbol(v, Decl(dependentDestructuredVariablesWithDefaults.ts, 45, 15)) + + | { t: "b", v: boolean }; +>t : Symbol(t, Decl(dependentDestructuredVariablesWithDefaults.ts, 46, 7)) +>v : Symbol(v, Decl(dependentDestructuredVariablesWithDefaults.ts, 46, 15)) + +function threeWay({ t = "i", v }: Three) { +>threeWay : Symbol(threeWay, Decl(dependentDestructuredVariablesWithDefaults.ts, 46, 29)) +>t : Symbol(t, Decl(dependentDestructuredVariablesWithDefaults.ts, 48, 19)) +>v : Symbol(v, Decl(dependentDestructuredVariablesWithDefaults.ts, 48, 28)) +>Three : Symbol(Three, Decl(dependentDestructuredVariablesWithDefaults.ts, 40, 1)) + + if (t === "s") { +>t : Symbol(t, Decl(dependentDestructuredVariablesWithDefaults.ts, 48, 19)) + + const s: string = v; +>s : Symbol(s, Decl(dependentDestructuredVariablesWithDefaults.ts, 50, 13)) +>v : Symbol(v, Decl(dependentDestructuredVariablesWithDefaults.ts, 48, 28)) + } +} + +// const destructuring (not a parameter). +declare const props: Props; +>props : Symbol(props, Decl(dependentDestructuredVariablesWithDefaults.ts, 55, 13)) +>Props : Symbol(Props, Decl(dependentDestructuredVariablesWithDefaults.ts, 0, 0)) + +function constDestructure() { +>constDestructure : Symbol(constDestructure, Decl(dependentDestructuredVariablesWithDefaults.ts, 55, 27)) + + const { isText = false, children } = props; +>isText : Symbol(isText, Decl(dependentDestructuredVariablesWithDefaults.ts, 57, 11)) +>children : Symbol(children, Decl(dependentDestructuredVariablesWithDefaults.ts, 57, 27)) +>props : Symbol(props, Decl(dependentDestructuredVariablesWithDefaults.ts, 55, 13)) + + if (isText === true) { +>isText : Symbol(isText, Decl(dependentDestructuredVariablesWithDefaults.ts, 57, 11)) + + const s: string = children; +>s : Symbol(s, Decl(dependentDestructuredVariablesWithDefaults.ts, 59, 13)) +>children : Symbol(children, Decl(dependentDestructuredVariablesWithDefaults.ts, 57, 27)) + } +} + +// --- Soundness boundary: these must still error --- + +// Optional discriminant + default: calling with the property omitted yields the default, +// so the sibling cannot be safely narrowed. +type OptDisc = +>OptDisc : Symbol(OptDisc, Decl(dependentDestructuredVariablesWithDefaults.ts, 61, 1)) + + | { kind?: "a", x: string } +>kind : Symbol(kind, Decl(dependentDestructuredVariablesWithDefaults.ts, 68, 7)) +>x : Symbol(x, Decl(dependentDestructuredVariablesWithDefaults.ts, 68, 19)) + + | { kind?: "b", x: number }; +>kind : Symbol(kind, Decl(dependentDestructuredVariablesWithDefaults.ts, 69, 7)) +>x : Symbol(x, Decl(dependentDestructuredVariablesWithDefaults.ts, 69, 19)) + +function optionalDiscriminant({ kind = "a", x }: OptDisc) { +>optionalDiscriminant : Symbol(optionalDiscriminant, Decl(dependentDestructuredVariablesWithDefaults.ts, 69, 32)) +>kind : Symbol(kind, Decl(dependentDestructuredVariablesWithDefaults.ts, 71, 31)) +>x : Symbol(x, Decl(dependentDestructuredVariablesWithDefaults.ts, 71, 43)) +>OptDisc : Symbol(OptDisc, Decl(dependentDestructuredVariablesWithDefaults.ts, 61, 1)) + + if (kind === "a") { +>kind : Symbol(kind, Decl(dependentDestructuredVariablesWithDefaults.ts, 71, 31)) + + const s: string = x; // error +>s : Symbol(s, Decl(dependentDestructuredVariablesWithDefaults.ts, 73, 13)) +>x : Symbol(x, Decl(dependentDestructuredVariablesWithDefaults.ts, 71, 43)) + } +} + +// Discriminant whose type already includes undefined. +type UndefDisc = +>UndefDisc : Symbol(UndefDisc, Decl(dependentDestructuredVariablesWithDefaults.ts, 75, 1)) + + | { kind: "a" | undefined, x: string } +>kind : Symbol(kind, Decl(dependentDestructuredVariablesWithDefaults.ts, 79, 7)) +>x : Symbol(x, Decl(dependentDestructuredVariablesWithDefaults.ts, 79, 30)) + + | { kind: "b", x: number }; +>kind : Symbol(kind, Decl(dependentDestructuredVariablesWithDefaults.ts, 80, 7)) +>x : Symbol(x, Decl(dependentDestructuredVariablesWithDefaults.ts, 80, 18)) + +function undefinedDiscriminant({ kind = "a", x }: UndefDisc) { +>undefinedDiscriminant : Symbol(undefinedDiscriminant, Decl(dependentDestructuredVariablesWithDefaults.ts, 80, 31)) +>kind : Symbol(kind, Decl(dependentDestructuredVariablesWithDefaults.ts, 82, 32)) +>x : Symbol(x, Decl(dependentDestructuredVariablesWithDefaults.ts, 82, 44)) +>UndefDisc : Symbol(UndefDisc, Decl(dependentDestructuredVariablesWithDefaults.ts, 75, 1)) + + if (kind === "a") { +>kind : Symbol(kind, Decl(dependentDestructuredVariablesWithDefaults.ts, 82, 32)) + + const s: string = x; // error +>s : Symbol(s, Decl(dependentDestructuredVariablesWithDefaults.ts, 84, 13)) +>x : Symbol(x, Decl(dependentDestructuredVariablesWithDefaults.ts, 82, 44)) + } +} + +// Sibling has its OWN default: it must not be narrowed from the parent. +type PropsOpt = +>PropsOpt : Symbol(PropsOpt, Decl(dependentDestructuredVariablesWithDefaults.ts, 86, 1)) + + | { isText: true, children?: string } +>isText : Symbol(isText, Decl(dependentDestructuredVariablesWithDefaults.ts, 90, 7)) +>children : Symbol(children, Decl(dependentDestructuredVariablesWithDefaults.ts, 90, 21)) + + | { isText: false, children?: number }; +>isText : Symbol(isText, Decl(dependentDestructuredVariablesWithDefaults.ts, 91, 7)) +>children : Symbol(children, Decl(dependentDestructuredVariablesWithDefaults.ts, 91, 22)) + +function siblingDefault({ isText = false, children = 0 }: PropsOpt) { +>siblingDefault : Symbol(siblingDefault, Decl(dependentDestructuredVariablesWithDefaults.ts, 91, 43)) +>isText : Symbol(isText, Decl(dependentDestructuredVariablesWithDefaults.ts, 93, 25)) +>children : Symbol(children, Decl(dependentDestructuredVariablesWithDefaults.ts, 93, 41)) +>PropsOpt : Symbol(PropsOpt, Decl(dependentDestructuredVariablesWithDefaults.ts, 86, 1)) + + if (isText === true) { +>isText : Symbol(isText, Decl(dependentDestructuredVariablesWithDefaults.ts, 93, 25)) + + const s: string = children; // error +>s : Symbol(s, Decl(dependentDestructuredVariablesWithDefaults.ts, 95, 13)) +>children : Symbol(children, Decl(dependentDestructuredVariablesWithDefaults.ts, 93, 41)) + } +} + diff --git a/tests/baselines/reference/dependentDestructuredVariablesWithDefaults.types b/tests/baselines/reference/dependentDestructuredVariablesWithDefaults.types new file mode 100644 index 0000000000000..d40326c88acf2 --- /dev/null +++ b/tests/baselines/reference/dependentDestructuredVariablesWithDefaults.types @@ -0,0 +1,383 @@ +//// [tests/cases/conformance/controlFlow/dependentDestructuredVariablesWithDefaults.ts] //// + +=== dependentDestructuredVariablesWithDefaults.ts === +// https://github.com/microsoft/TypeScript/issues/50139 +// A default on a *required* discriminant must not disable discriminant narrowing of siblings. + +type Props = +>Props : Props +> : ^^^^^ + + | { isText: true, children: string } +>isText : true +> : ^^^^ +>true : true +> : ^^^^ +>children : string +> : ^^^^^^ + + | { isText: false, children: number }; +>isText : false +> : ^^^^^ +>false : false +> : ^^^^^ +>children : number +> : ^^^^^^ + +// Baseline: no default — narrows (already worked). +function noDefault({ isText, children }: Props) { +>noDefault : ({ isText, children }: Props) => void +> : ^ ^^ ^^^^^^^^^ +>isText : boolean +> : ^^^^^^^ +>children : string | number +> : ^^^^^^^^^^^^^^^ + + if (isText === true) { +>isText === true : boolean +> : ^^^^^^^ +>isText : boolean +> : ^^^^^^^ +>true : true +> : ^^^^ + + const s: string = children; +>s : string +> : ^^^^^^ +>children : string +> : ^^^^^^ + + } else { + const n: number = children; +>n : number +> : ^^^^^^ +>children : number +> : ^^^^^^ + } +} + +// The bug: default on a required discriminant should still narrow siblings. +function withDefault({ isText = false, children }: Props) { +>withDefault : ({ isText, children }: Props) => void +> : ^ ^^ ^^^^^^^^^ +>isText : boolean +> : ^^^^^^^ +>false : false +> : ^^^^^ +>children : string | number +> : ^^^^^^^^^^^^^^^ + + if (isText === true) { +>isText === true : boolean +> : ^^^^^^^ +>isText : boolean +> : ^^^^^^^ +>true : true +> : ^^^^ + + const s: string = children; +>s : string +> : ^^^^^^ +>children : string +> : ^^^^^^ + + } else { + const n: number = children; +>n : number +> : ^^^^^^ +>children : number +> : ^^^^^^ + } +} + +// switch form. +function withDefaultSwitch({ isText = false, children }: Props) { +>withDefaultSwitch : ({ isText, children }: Props) => void +> : ^ ^^ ^^^^^^^^^ +>isText : boolean +> : ^^^^^^^ +>false : false +> : ^^^^^ +>children : string | number +> : ^^^^^^^^^^^^^^^ + + switch (isText) { +>isText : boolean +> : ^^^^^^^ + + case true: { const s: string = children; break; } +>true : true +> : ^^^^ +>s : string +> : ^^^^^^ +>children : string +> : ^^^^^^ + + case false: { const n: number = children; break; } +>false : false +> : ^^^^^ +>n : number +> : ^^^^^^ +>children : number +> : ^^^^^^ + } +} + +// Renamed binding `{ isText: t = false }`. +function renamed({ isText: t = false, children }: Props) { +>renamed : ({ isText: t, children }: Props) => void +> : ^ ^^ ^^^^^^^^^ +>isText : any +> : ^^^ +>t : boolean +> : ^^^^^^^ +>false : false +> : ^^^^^ +>children : string | number +> : ^^^^^^^^^^^^^^^ + + if (t === true) { +>t === true : boolean +> : ^^^^^^^ +>t : boolean +> : ^^^^^^^ +>true : true +> : ^^^^ + + const s: string = children; +>s : string +> : ^^^^^^ +>children : string +> : ^^^^^^ + + } else { + const n: number = children; +>n : number +> : ^^^^^^ +>children : number +> : ^^^^^^ + } +} + +// Three-way union with a string discriminant. +type Three = +>Three : Three +> : ^^^^^ + + | { t: "i", v: number } +>t : "i" +> : ^^^ +>v : number +> : ^^^^^^ + + | { t: "s", v: string } +>t : "s" +> : ^^^ +>v : string +> : ^^^^^^ + + | { t: "b", v: boolean }; +>t : "b" +> : ^^^ +>v : boolean +> : ^^^^^^^ + +function threeWay({ t = "i", v }: Three) { +>threeWay : ({ t, v }: Three) => void +> : ^ ^^ ^^^^^^^^^ +>t : "i" | "s" | "b" +> : ^^^^^^^^^^^^^^^ +>"i" : "i" +> : ^^^ +>v : string | number | boolean +> : ^^^^^^^^^^^^^^^^^^^^^^^^^ + + if (t === "s") { +>t === "s" : boolean +> : ^^^^^^^ +>t : "i" | "s" | "b" +> : ^^^^^^^^^^^^^^^ +>"s" : "s" +> : ^^^ + + const s: string = v; +>s : string +> : ^^^^^^ +>v : string +> : ^^^^^^ + } +} + +// const destructuring (not a parameter). +declare const props: Props; +>props : Props +> : ^^^^^ + +function constDestructure() { +>constDestructure : () => void +> : ^^^^^^^^^^ + + const { isText = false, children } = props; +>isText : boolean +> : ^^^^^^^ +>false : false +> : ^^^^^ +>children : string | number +> : ^^^^^^^^^^^^^^^ +>props : Props +> : ^^^^^ + + if (isText === true) { +>isText === true : boolean +> : ^^^^^^^ +>isText : boolean +> : ^^^^^^^ +>true : true +> : ^^^^ + + const s: string = children; +>s : string +> : ^^^^^^ +>children : string +> : ^^^^^^ + } +} + +// --- Soundness boundary: these must still error --- + +// Optional discriminant + default: calling with the property omitted yields the default, +// so the sibling cannot be safely narrowed. +type OptDisc = +>OptDisc : OptDisc +> : ^^^^^^^ + + | { kind?: "a", x: string } +>kind : "a" | undefined +> : ^^^^^^^^^^^^^^^ +>x : string +> : ^^^^^^ + + | { kind?: "b", x: number }; +>kind : "b" | undefined +> : ^^^^^^^^^^^^^^^ +>x : number +> : ^^^^^^ + +function optionalDiscriminant({ kind = "a", x }: OptDisc) { +>optionalDiscriminant : ({ kind, x }: OptDisc) => void +> : ^ ^^ ^^^^^^^^^ +>kind : "b" | "a" +> : ^^^^^^^^^ +>"a" : "a" +> : ^^^ +>x : string | number +> : ^^^^^^^^^^^^^^^ + + if (kind === "a") { +>kind === "a" : boolean +> : ^^^^^^^ +>kind : "b" | "a" +> : ^^^^^^^^^ +>"a" : "a" +> : ^^^ + + const s: string = x; // error +>s : string +> : ^^^^^^ +>x : string | number +> : ^^^^^^^^^^^^^^^ + } +} + +// Discriminant whose type already includes undefined. +type UndefDisc = +>UndefDisc : UndefDisc +> : ^^^^^^^^^ + + | { kind: "a" | undefined, x: string } +>kind : "a" | undefined +> : ^^^^^^^^^^^^^^^ +>x : string +> : ^^^^^^ + + | { kind: "b", x: number }; +>kind : "b" +> : ^^^ +>x : number +> : ^^^^^^ + +function undefinedDiscriminant({ kind = "a", x }: UndefDisc) { +>undefinedDiscriminant : ({ kind, x }: UndefDisc) => void +> : ^ ^^ ^^^^^^^^^ +>kind : "b" | "a" +> : ^^^^^^^^^ +>"a" : "a" +> : ^^^ +>x : string | number +> : ^^^^^^^^^^^^^^^ + + if (kind === "a") { +>kind === "a" : boolean +> : ^^^^^^^ +>kind : "b" | "a" +> : ^^^^^^^^^ +>"a" : "a" +> : ^^^ + + const s: string = x; // error +>s : string +> : ^^^^^^ +>x : string | number +> : ^^^^^^^^^^^^^^^ + } +} + +// Sibling has its OWN default: it must not be narrowed from the parent. +type PropsOpt = +>PropsOpt : PropsOpt +> : ^^^^^^^^ + + | { isText: true, children?: string } +>isText : true +> : ^^^^ +>true : true +> : ^^^^ +>children : string | undefined +> : ^^^^^^^^^^^^^^^^^^ + + | { isText: false, children?: number }; +>isText : false +> : ^^^^^ +>false : false +> : ^^^^^ +>children : number | undefined +> : ^^^^^^^^^^^^^^^^^^ + +function siblingDefault({ isText = false, children = 0 }: PropsOpt) { +>siblingDefault : ({ isText, children }: PropsOpt) => void +> : ^ ^^ ^^^^^^^^^ +>isText : boolean +> : ^^^^^^^ +>false : false +> : ^^^^^ +>children : string | number +> : ^^^^^^^^^^^^^^^ +>0 : 0 +> : ^ + + if (isText === true) { +>isText === true : boolean +> : ^^^^^^^ +>isText : boolean +> : ^^^^^^^ +>true : true +> : ^^^^ + + const s: string = children; // error +>s : string +> : ^^^^^^ +>children : string | number +> : ^^^^^^^^^^^^^^^ + } +} + diff --git a/tests/cases/conformance/controlFlow/dependentDestructuredVariablesWithDefaults.ts b/tests/cases/conformance/controlFlow/dependentDestructuredVariablesWithDefaults.ts new file mode 100644 index 0000000000000..6f3d3e8d0249d --- /dev/null +++ b/tests/cases/conformance/controlFlow/dependentDestructuredVariablesWithDefaults.ts @@ -0,0 +1,101 @@ +// @strict: true +// @noEmit: true + +// https://github.com/microsoft/TypeScript/issues/50139 +// A default on a *required* discriminant must not disable discriminant narrowing of siblings. + +type Props = + | { isText: true, children: string } + | { isText: false, children: number }; + +// Baseline: no default — narrows (already worked). +function noDefault({ isText, children }: Props) { + if (isText === true) { + const s: string = children; + } else { + const n: number = children; + } +} + +// The bug: default on a required discriminant should still narrow siblings. +function withDefault({ isText = false, children }: Props) { + if (isText === true) { + const s: string = children; + } else { + const n: number = children; + } +} + +// switch form. +function withDefaultSwitch({ isText = false, children }: Props) { + switch (isText) { + case true: { const s: string = children; break; } + case false: { const n: number = children; break; } + } +} + +// Renamed binding `{ isText: t = false }`. +function renamed({ isText: t = false, children }: Props) { + if (t === true) { + const s: string = children; + } else { + const n: number = children; + } +} + +// Three-way union with a string discriminant. +type Three = + | { t: "i", v: number } + | { t: "s", v: string } + | { t: "b", v: boolean }; + +function threeWay({ t = "i", v }: Three) { + if (t === "s") { + const s: string = v; + } +} + +// const destructuring (not a parameter). +declare const props: Props; +function constDestructure() { + const { isText = false, children } = props; + if (isText === true) { + const s: string = children; + } +} + +// --- Soundness boundary: these must still error --- + +// Optional discriminant + default: calling with the property omitted yields the default, +// so the sibling cannot be safely narrowed. +type OptDisc = + | { kind?: "a", x: string } + | { kind?: "b", x: number }; + +function optionalDiscriminant({ kind = "a", x }: OptDisc) { + if (kind === "a") { + const s: string = x; // error + } +} + +// Discriminant whose type already includes undefined. +type UndefDisc = + | { kind: "a" | undefined, x: string } + | { kind: "b", x: number }; + +function undefinedDiscriminant({ kind = "a", x }: UndefDisc) { + if (kind === "a") { + const s: string = x; // error + } +} + +// Sibling has its OWN default: it must not be narrowed from the parent. +type PropsOpt = + | { isText: true, children?: string } + | { isText: false, children?: number }; + +function siblingDefault({ isText = false, children = 0 }: PropsOpt) { + if (isText === true) { + const s: string = children; // error + } +}