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?
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.