typescripttypescript-generics

Why doesn't TypeScript narrow the value type based on a narrowed keyof in control flow?


I am trying to understand how TypeScript handles narrowing in the following examples:

type ValueMapping = {
    a: number;
    b: string;
}

type ReturnTypeMapping = {
    a: number;
    b: string;
}

export function example1 (
  key: keyof ValueMapping,
  value: ValueMapping[typeof key]
): ReturnTypeMapping[typeof key] {

    if(key === 'a') {
        key          // key: 'a' ✔️ - is narrowed
        key === 'b'  // type error ✔️
        value        // value: string | number ❌ - Expected narrowing to `number`
        return 3     // no error ❌ - Expected return type to narrow and produce an error
    }

    return 3;        // no error ❌ 
}

export function example2<T extends keyof ValueMapping> (
  key: T,
  value: ValueMapping[T]
): ReturnTypeMapping[T] {

    if(key === 'a') {
        key          // key: T extends keyof ValueMapping ❌ - not narrowed
        key === 'b'  // no error ❌
        value        // value: ValueMapping[T] ❌ - Expected to narrow to `number`
        return 3     // error ❌ - "Type '3' is not assignable to type 'never'"
    }
    
    return 3;        // error ❌ - "Type '3' is not assignable to type 'never'"
}

My expectation was that inside if the type of key, value and return type would narrow to

key: 'a'
value: 'number'
return: 'number'

It there another way to achieve that?


Solution

  • The problem with both your example1 and example2 functions is that neither of them actually represent the intended restriction that they can only be called where value definitely corresponds to key.

    For example1 this is immediately obvious:

    example1("a", "oops"); // no error
    

    That's allowed because typeof key is not implicitly generic. You annotated key as keyof ValueMapping, which is the union "a" | "b". So typeof key is just that same union, and thus value is of type ValueMapping["a" | "b"] which is itself the union string | number. So key can be either "a" or "b", and value can be either string or number, with no correlation between them. And that means TypeScript cannot narrow value when you check key inside the function.

    For example2 you made the function explicitly generic, and so there is some correlation between key and value. Indeed the call above fails, as desired:

    example2("a", "oops"); // error Argument of type 'string' is not assignable to parameter of type 'number'.
    

    Here T is inferred to be "a", and so value is of type ValueMapping["a"], which is just number, so "oops" is rejected because it's a string.

    But unfortunately this isn't enough. Nothing stops T from being inferred as the full union "a" | "b", which can be seen if you pass in a key of that union type:

    example2(Math.random() < 0.99 ? "a" : "b", "oops"); // no error
    

    There's a 99% chance that we've called example2 with a key of "a" but a string value. So TypeScript cannot narrow value when checking key.

    You might hope that there'd be a way to make sure a generic type argument is not a union, and that it's always exactly one of the union members... either "a" or "b", but not "a" | "b". But there isn't. There's a longstanding open issue at microsoft/TypeScript#27808 for such a functionality, but it has not been implemented yet. For now, if you use generics, you will need to expect that it's possible for value to be either string or number no matter what key is.


    There is one way to ensure that callers are required to call only supported combinations of key and value, and which is recognized as such inside the implementation. Instead of generics, you can make the function's parameter list a rest parameter of a discriminated union of tuple types:

    type KeyValue = { [K in keyof ValueMapping]: [K, ValueMapping[K]] }[keyof ValueMapping];
    //   ^? type KeyValue = ["a", number] | ["b", string]
    
    export function example3(...[key, value]: KeyValue) {}
    

    Here the input rest parameter is of the union type ["a", number] | ["b", string], and we have immediately destructured it into key and value parameters. That means you can either call example3(key: "a", value: number) or you can call example3(key: "b", value: string), and that's it:

    example3("a", "oops"); // error
    example3(Math.random() < 0.99 ? "a" : "b", "oops"); // error
    example3("a", 123); // okay
    

    Furthermore, inside the function, you can take advantage of control flow analysis for destructured discriminated unions, where key is the discriminant:

    function example3(...[key, value]: KeyValue): ReturnTypeMapping[typeof key] {
        if (key === 'a') {
            key
            //^? (parameter) key: "a"
            key === 'b'
            value
            //^? (parameter) value: number
            return 3
        }
        key
        //^? (parameter) key: "b"
        value
        //^? (parameter) value:string
        return 3; // no error
    }
    

    This gives you almost everything you want. Except, the function is not generic, so the function's return type is just ReturnTypeMapping[typeof key], which is just the union string | number again. You can't use a non-generic call signature and have the function's return type depend on the inputs. So unfortunately there's no error at the last return 3, even though value has been narrowed. And callers don't see string or number as the return type, just string | number.

    There's no compiler-verified type-safe way around this. You can, if you want, rewrite your function as overloads to at least allow callers to see what they expect:

    // call signatures
    function example4(key: "a", value: number): number;
    function example4(key: "b", value: string): string; // error since string is never returned
    
    // implementation
    function example4(...[key, value]: KeyValue) {
        if (key === 'a') {
            key
            //^? (parameter) key: "a"
            key === 'b'
            value
            //^? (parameter) value: number
            return 3
        }
        key
        //^? (parameter) key: "b"
        value
        //^? (parameter) value:string
        return 3; // no error
    }
    
    const v = example4("a", 123); // okay
    //    ^? const v: number
    

    And it kind of checks the return type if you completely fail to return one of the expected types; above, no string is ever returned, so the second call signature gives an error. You can fix it by changing the bottom return 3 to return "whatever"... but you can also fix it by changing the top return 3. There's no compiler verified return type narrowing.

    So this is kind of where we end. You could possibly completely refactor your code so that you do generic indexed accesses instead of narrowing, but this has other issues, and we're getting outside the scope of the question as asked.

    Playground link to code