Search code examples
typescript

Function parameter type which depends on type of another parameter using conditional types


Here:

function foo<T extends boolean> ( multiple: T,  value: T extends true ? number[] : number) {
  if (multiple) {
    let test: number[] =  value; //   Type 'number' is not assignable to type 'number[]'.(2322)

  }

 
}

I was expecting inside if, value to be resolved as number[] no?


Initially had tried without generics too but had same issues:

function foo ( multiple: boolean,  value: typeof multiple extends true ? number[] : number) {
  if (multiple) {
    let test: number[] =  value; //   Type 'number' is not assignable to type 'number[]'.(2322)

  }

 
}

Solution

  • TypeScript can't currently mix generics with control flow analysis very well. When you check multiple, TypeScript can narrow multiple from T to, say, T & true. But it cannot narrow or re-constrain T itself. You'd like to say that if multiple is true then T must also be true. But that's actually not the case, as you can verify by calling:

    foo(Math.random() < 0.99, 1); // no compiler error, 99% chance of failure
    

    Here Math.random() < 0.99 is of type boolean which is the union type true | false, so the distributive conditional type T extends true ? number[] : number evaluates to the union number[] | number, and suddenly foo() accepts possibly mismatched arguments.

    There is a longstanding open feature request at microsoft/TypeScript#27808 to allow someone to have a generic function where T is either true or false and cannot be the union true | false. But for now it's not part of the language. Additionally there's the general problem whereby TypeScript really can't see if some value is or is not assignable to a generic conditional type. The feature request at microsoft/TypeScript#33912 deals with that. Until and unless these are implemented, you'll have to change what you're doing.


    In general the only surefire way to work around this is with type assertions. But in this case your function's return type does not depend on its input (it's always void) and the argument list looks like a discriminated union [true, number[]] | [false, number] because true and false are valid discriminants.

    So you can refactor your code to use control flow analysis instead of generics. The function can accept a rest parameter whose type is a destructured discriminated union of tuple types:

    function foo(...[multiple, value]:
        [multiple: true, value: number[]] |
        [multiple: false, value: number]
    ) {
        if (multiple) {
            let test: number[] = value; // okay
        }
    }
    

    That works because TypeScript now understands that checking multiple will narrow the discriminated union represented by [multiple, value]. When you call foo() it looks like an overloaded function:

    foo(^
    // 1/2 foo(multiple: true, value: number[]): void
    // 2/2 foo(multiple: false, value: number): void
    

    And you can call it in the ways you want

    foo(true, [1, 2, 3]);
    foo(false, 4);
    

    And if you call it in invalid ways you get an error, even in the Math.random() case:

    foo(true, 5); // error
    foo(Math.random() < 0.99, 1); //error
    

    That's because [boolean, number] is not assignable to [true, number[]] | [false, number].

    Playground link to code