Repository navigation
narrowing in switch doesn't work with non-union types #16976
Description
Activity
- addedIn DiscussionNot yet reached consensusNot yet reached consensusSuggestionAn idea for TypeScriptAn idea for TypeScript
on Jul 6, 2017 RyanCavanaugh commented
on Jul 6, 2017 MemberMore actionsCurrently there's no logic in effect for "narrowing" of non-unions.
It'd be good to collect more use cases where behavior like this could be useful
zpdDG4gta8XKpMCd commented
on Jul 6, 2017 AuthorMore actionswell, my use case is the consistency with other places where U is a true union (has more than one case)
DylanRJohnston commented
on Jul 27, 2017 More actionsRyan Cavanaugh (@RyanCavanaugh)
I've also run into this problem when trying to write error handling code that should be extensible with more error cases in the future.
const assertNever = (x: never): never => { throw new Error(`Unexpected object ${x}`) } type Foo = | { kind: 'kind1', x: number } // | { kind: 'kind2', y: string } function foo(f: Foo) { switch (f.kind) { case 'kind1': return "" // case 'kind2': return "" default: return assertNever(f) } }
Type error
[ts] Argument of type 'Foo' is not assignable to parameter of type 'never'.
Shouldn't a single value be treated as a union with a single element? So the narrowing here should result in f being narrowed to never?
Reacted by Drew PotterzpdDG4gta8XKpMCd commented
on Aug 7, 2017 AuthorMore actionsinterestingly narrowing works for single string literals, which is at very least looks inconsistent with this very issue
type U = 'a'; // <-- supposed to be a single case union declare var u: U; declare function never(never: never): never; function fn() { switch (u) { case 'a': return 1; default: return never(u); // <-- WORKS! } }
Reacted by Dylan R. Johnston and Andrew PyleRyan Cavanaugh (@RyanCavanaugh),
Can be useful when designing API that don't have union initially, but later got it:
// Initial { type DbError = { tag: "DbError" } const updateDB = (onError: (error: DbError) => void) => { } updateDB(error => { switch (error.tag) { case "DbError": const e1: DbError = error; console.log("DbError happened"); break; default: // Problem, all possible errors are handled const e2: never = error; // Type 'DbError' is not assignable to type 'never'. console.log("Unhandled error"); break; } }) } // Sometimes later { type DbError = { tag: "DbError" } type NewDbError = { tag: "NewDbError" } const updateDB = (onError: (error: DbError | NewDbError) => void) => { } updateDB(error => { switch (error.tag) { case "DbError": const e1: DbError = error; console.log("DbError happened"); break; default: // Correct, need to update code to handle new error type const e2: never = error; // Type 'NewDbError' is not assignable to type 'never'. console.log("Unhandled error"); break; } }) }
Reacted by Andrew Pylecross-referencing to this relevant comment on #8513
I noticed this too when dealing with array types and expecting them to be narrowed, yet unexpectedly they aren't:
function test(input: (string | number)[]): string[] { // The type of .map(...) reports that it returns string[]. input = input.map(x => x.toString()) // Type error: Type '(string | number)[]' is not assignable to type 'string[]'. return input }
In my current code base I allow many different types for most my arguments to keep them flexible in use, but normalize them to one type at the start. Right now, in the case of my array arguments that allow different element types, I have to workaround it via reassignment or casting on use.
My Stack Overflow question about it: https://stackoverflow.com/questions/68928896/why-doesnt-typescript-narrow-array-types
Cross-linking to #10065
DylanRJohnston commented
on Aug 27, 2021 More actionsI noticed this too when dealing with array types and expecting them to be narrowed, yet unexpectedly they aren't:
function test(input: (string | number)[]): string[] { // The type of .map(...) reports that it returns string[]. input = input.map(x => x.toString()) // Type error: Type '(string | number)[]' is not assignable to type 'string[]'. return input }
In my current code base I allow many different types for most my arguments to keep them flexible in use, but normalize them to one type at the start. Right now, in the case of my array arguments that allow different element types, I have to workaround it via reassignment or casting on use.
My Stack Overflow question about it: https://stackoverflow.com/questions/68928896/why-doesnt-typescript-narrow-array-types
It's because you're reusing the variable
input, just return that expression instead and you're all good.Dylan R. Johnston (@DylanRJohnston) I am well aware that would work in my simplified example, but it should have been clear that is not what I meant. If you read the linked Stack Overflow question, you would know that's not what I am after, just one of the available workarounds of the problem. Just imagine I had other code between the reassignment and return. My problem is that it is not narrowing the type as expected, not that I don't have any workarounds available.
Just ran into this problem (playground):
type SingleType = { type: "a" }; function broken(value: SingleType): void { switch (value.type) { case "a": break; default: const exhaust: never = value; // Compile error: Type 'SingleType' is not assignable to type 'never'. } } type UnionType = { type: "a" } | { type: "b" }; function working(value: UnionType): void { switch (value.type) { case "a": break; case "b": break; default: const exhaust: never = value; // Correctly narrowed to `never`. This is what I expect! } }
Interestingly it works with the
typeofopeartor:type A = number; type B = string; type U = A; function never(arg: never): never { throw new TypeError("type union not exhausted"); } function works(arg: U) { if (typeof arg === "number") return true; never(arg); } function worksToo(arg: U) { switch(typeof arg) { case "number": return true; default: return never(arg); } }
So it seems that exhaustion works with simple types (number, boolean, string, string literals, ...), but not with properties on objects. This is somewhat inconsistent as ZpdDG4gta (@zpdDG4gta8XKpMCd) stated here (comment)
For those looking for a way to work around this issue, I played around with trying to force typescript to interpret my type as a union with one element. Simply adding
| neverdoesn't seem to work, example:declare const unreachable: (e: never) => never; type Container<T> = { type: T }; declare const value: Container<'ONE'> | never; switch(value.type) { case 'ONE': break; default: unreachable(value); // <-- TS2345: Argument of type 'Container"ONE">' is not assignable to parameter of type 'never'. }
However, for whatever reason, if the type you're attempting to narrow is an object type you can union it with an object with
neverfields. This does correctly narrow (at least in TS v4.9.5)declare const unreachable: (e: never) => never; type Container<T> = { type: T }; declare const value: Container<'ONE'> | Container<never>; switch(value.type) { case 'ONE': break; default: unreachable(value); // <-- WORKS! }
This still works even when the union is expanded to more than one "valid" element:
declare const unreachable: (e: never) => never; type Container<T> = { type: T }; declare const value: Container<'ONE'> | Container<'TWO'> | Container<never>; switch(value.type) { case 'ONE': break; case 'TWO': break; default: unreachable(value); // <-- WORKS! }
I ran into this issue when designing a function that takes variadic arguments and returns a mapped union of the types given. When only one argument was given, I would need a different check in the default switch statement. With this "trick" (read: egregious hack) I can add/remove cases without changing the default case.