-
Notifications
You must be signed in to change notification settings - Fork 12.8k
narrowing in switch doesn't work with non-union types #16976
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Comments
Currently 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 |
well, my use case is the consistency with other places where U is a true union (has more than one case) |
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? |
interestingly 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!
}
} |
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;
}
})
} |
cross-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 |
It's because you're reusing the variable |
@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 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 @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 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 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. |
The text was updated successfully, but these errors were encountered: