I'm puzzled by the following problem. I have a simple tagged union:
type MyUnion =
| { tag: "Foo"; field: string; }
| { tag: "Bar"; }
| null;
And now I have this generic function:
const foo = <T extends MyUnion>(value: T) => {
// Narrowing works as expected.
if (value === null) { return; }
const notNull: NonNullable<T> = value;
// Narrowing works with field access, but not with `Extract` type.
if (value.tag !== "Foo") { return; }
const s: string = value.field; // Field can be accessed -> all good here.
const extracted: Extract<T, { tag: "Foo" }> = value; // error!
};
The last line results in this error:
Type 'T & {}' is not assignable to type 'Extract<T, { tag: "Foo"; }>'.
And I really can't understand why. Writing it outside the function without the generic as Extract<MyUnion, { tag: "Foo" }>
results in exactly the correct type. What's going on here?
Note: in my real code, the function has another parameter callback: (v: Extract<T, { tag: "Foo" }>) => void. And calling that callback leads to the same type error. So my goal is to somehow make this work: Playground. So the function foo
basically performs specific checks on a value, handles special cases somehow, and then calls the callback with the sanitized value. I would assume TypeScript somehow allows me to express this kind of abstraction?
CodePudding user response:
This can be seen as a design limitation in TypeScript. This issue is that Extract
is a conditional type which depends on a generic type T
.
T
is pretty much unspecified at the point in code where you are using the conditional. Sure, T
has a constraint which forces it to be a subtype of MyUnion
. But no concrete type is known yet because it is specified by the caller of the function. Whenever the compiler encounters a conditional type like that, it is unable to evaluate it. The type stays essentially opaque to a point where only values of type Extract<T, { tag: "Foo" }>
can be safely assigned to it.
You can find disussions of similar issues in #33484 or #28884. There are lots of situations regarding generics and conditionals where the typing may be sound to a human reading the code but where the compiler stops reasoning because of yet unresolved types.
You may be wondering why this works:
const notNull: NonNullable<T> = value;
#49119 changed NonNullable
from a conditional type to one which simply intersects T
with {}
.
- type NonNullable<T> = T extends null | undefined ? never : T;
type NonNullable<T> = T & {};
The same PR also introduced improvements to control flow anaylsis. Checking if a variable with a generic type is not null
would intersect the type of the variable with {}
.
This makes assignability possible since both NonNullable<T>
and the type of value
evaluate to T & {}