Types narrowing with generics
Asked Answered
S

2

11

I would like to link 2 generics types in a function, and use narrowing for both types by checking one of them. What is the correct way to do this?

type A = 'A';
type B = 'B';

type AB = A | B

type ComplexType<T> = {value: T}

const f = (next: ComplexType<A>) => {}

const builder = <T extends AB>(value: T) => (next: ComplexType<T>) => {
    if (value === 'A') {
        f(next) // expect next is ComplexType<A> but got error
    }
}
Sucrose answered 23/8, 2021 at 18:11 Comment(2)
You have to consider the case where T is AB itself: playgroundAnaphrodisiac
Yeah, nothing stops someone from calling builder(Math.random()<=0.999 ? "A" : "B")({value: "B"}) and having a high chance of the wrong thing happening. This issues is microsoft/TypeScript#27808 and the workaround for now is probably just "use a type assertion".Pursuer
P
10

There is currently no way to narrow a type parameter like T by checking a value like value. It is possible that value === "A" is true but that does not mean T is "A". After all, maybe value is of type "A" | "B", say by pass ing in an expression where the compiler infers the full union type:

builder(Math.random() <= 0.999 ? "A" : "B") // no error

Here there is a 99.9% chance that you've passed in "A" but it's still possible that you've passed in "B". The compiler infers that T is "A" | "B". And therefore the next parameter will be of type ComplexType<"A" | "B">. So there is no compiler error when you call this:

builder(Math.random() <= 0.999 ? "A" : "B")({ value: "B" }); // no error

which means the compiler is technically correct that f(next) might be in error.


There are multiple existing issues in GitHub asking for support for narrowing type parameters inside generic function bodies. The most relevant for your code is probably microsoft/TypeScript#27808. This asks for some way to tell the compiler that T should be either "A" or "B" and not "A" | "B". Maybe the syntax would be something like T extends_oneof [A, B] or (T extends A) | (T extends B) or something completely different. Then perhaps when you test value === "A" the compiler would conclude that T extends A, and everything would work. Alas, there is currently no such support.


For now then you just have to work around it. If you're fairly confident nobody is going to call your builder() incorrectly, you could just use a type assertion and move on:

const builder = <T extends AB>(value: T) => (next: ComplexType<T>) => {
  if (value === 'A') {
    f(next as ComplexType<A>) // okay
  }
}

If you really need to prevent callers from doing the wrong thing with builder() you could make increasingly complicated call signatures that amount to simulating the "extends one of" constraint, like:

type NoneOf<T, U extends any[]> =
  [T] extends [U[number]] ? never : T;
type OneOf<T, U extends any[]> =
  U extends [infer F, ...infer R] ? [T] extends [F] ? NoneOf<T, R> : OneOf<T, R> : never;

const builder = <T extends "A" | "B">(
  value: T & OneOf<T, ["A", "B"]>
) => (next: ComplexType<T>) => {
  if (value === 'A') {
    f(next as ComplexType<"A">)
  }
}

builder(Math.random() <= 0.999 ? "A" : "B"); // error now
builder2("A") // okay
builder2("B") // okay

but of course the compiler can't follow that inside the body of builder anyway (generic conditional types are hard for it to deal with) so you still need the type assertion. Personally I'd just use your original signature with the type assertion and only revisit anything more complex if you run into invalid calls in practice.

Playground link to code

Pursuer answered 23/8, 2021 at 20:54 Comment(0)
V
-1

You need to make your function f aware of your generic too.

Changing

const f = (next: ComplexType<A>) => {}

to

const f = <T extends AB>(next: ComplexType<T>) => {}

should work.

Vitavitaceous answered 23/8, 2021 at 18:27 Comment(1)
the idea is function "f" should work with narrowed type ASucrose

© 2022 - 2024 — McMap. All rights reserved.