Home > Blockchain >  Unexpected type error in interaction of type narrowing and `Extract` helper with generic type
Unexpected type error in interaction of type narrowing and `Extract` helper with generic type

Time:11-21

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!
};

(Playground)

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 & {}

  • Related