Reputation: 33
Say I have this example:
type Keys = 'one' | 'two';
type Values<T extends Keys> = T extends 'one'
? { id: 1, name: 'hello1' }
: T extends 'two'
? { id: 2, value: 'hello2' }
: never;
type Fn = <T extends Keys>(key: T) => (value: Values<T>) => void;
const fn: Fn = (key) => (value) => {
if (key === 'one') {
const name = value.name; // This throws compile error
}
}
// Both of these `fn` calls correctly limit the `value` argument based on the `key`
fn('one')({ id: 1, name: 'hello1' });
fn('two')({ id: 2, value: 'hello2' });
Can someone explain to me why this setup correctly limits the allowed types for the value
argument when invoking the fn
method, but doesn't allow me to narrow value
after using a Type Guard inside the fn
implementation?
Playground - I have this playground with a few other attempts to make this work, but not success.
Ultimately I'm trying to use something similar to this as a useCallback
method in a React project where an onChange
prop is set with the callback invoked with a key, then the event sends the value. Since I know the types of the event, I feel like this should be possible. Ex:
<Component1 onChange={handleChange('key1')} />
<Component2 onChange={handleChange('key2')} />
Upvotes: 3
Views: 298
Reputation: 328142
First let's dispense with your conditional type definition of Values<K>
. This can be easily refactored to an indexed access into a mapping interface:
interface KeyVals {
one: { id: 1, name: "hello1" },
two: { id: 2, value: "hello2" }
}
type Keys = keyof KeyVals;
type Values<K extends Keys> = KeyVals[K];
The compiler is slightly better at analyzing indexed accesses than it at analyzing conditional types. This doesn't fix your problem, but it does make my workaround behave better, so I'll do that.
The main problem here is the mismatch between how the compiler deals with discriminated unions and how it deals with generics. Your fn()
function call signature is generic, with values k
of type K
and v
of type Values<K>
, but your function implementation needs to treat these as discriminated unions, where checking the value of k
should narrow the type of v
. But checking k
only narrows the type of k
, and does narrow the type parameter K
. So you can't treat these generic values as discriminated unions.
This is sort of a general limitation in TypeScript currently, with various issues in GitHub raised about different aspects of it. For example, there's microsoft/TypeScript#33014, which explicitly asks for a check on k
to narrow the type parameter K
. That issue is currently open. It would probably also need microsoft/TypeScript#27808 to be implemented, so that you can prevent people from passing in a union for K
, since that messes things up. There's also microsoft/TypeScript#30581 which asks for how to treat unions generically, and the recommended fix/approach to it at microsoft/TypeScript#47109 is to essentially reframe unions as generics... but here you need the union behavior to work. Right now this is just not possible, it seems.
You also tried the approach of making fn()
a non-generic overloaded function. This would avoid generics entirely. Unfortunately the compiler is even less able to analyze overloads than it is with generics. Inside the implementation of an overloaded function statement the compiler does not constrain things to only be compatible with the call signatures; it only looks at the implementation signature, and it matches the implementation and call signatures very loosely. See microsoft/TypeScript#13235 for a declined request to have overloads checked in a type safe way. And if you use an overloaded function expression then the compiler is too strict. See microsoft/TypeScript#47669 for a still-open request to do something different here. So overloads can either be made to suppress errors (but allow some unsafe things) or catch errors (but prevent some safe things) but not both.
So, as far as I know, there's no way to refactor your code so that the compiler allows you to do the right thing while stopping you from doing the wrong thing. All I can think of is a workaround.
In this case, the closest I can get to useful safety is to use a function overload statement to bridge the gap between generics and discriminated unions. It looks like this:
function uncurriedFn<K extends Keys>(k: K, v: Values<K>): void;
function uncurriedFn(...[k, v]: { [K in Keys]: [k: K, v: Values<K>] }[Keys]) {
if (k === 'one') {
const name = v.name;
}
}
So uncurriedFn()
is a single function that takes both k
and v
instead of the curried version that takes k
and returns a function that takes v
. The call signature of the function is generic, so you should be able to call it safely as a generic function. In fact, that lets you use it inside your intended curried version:
type Fn = <K extends Keys>(k: K) => (v: Values<K>) => void;
const fn: Fn = (k) => (v) => uncurriedFn(k, v)
fn('one')({ id: 1, name: 'hello1' });
fn('two')({ id: 2, value: 'hello2' });
So that's the call signature. What about the implementation? Well, the implementation signature is non-generic and takes a destructured discriminanted union argument. The type {[K in Keys]: [k: K, v: Values<K>]}[Keys]
is a distributive object (as coined in ms/TS#47109) that evaluates to [k: 'one', v: Values<'one'>] | [k: 'two', v: Values<'two'>]
. This also only allows safe calls; it will complain if you mismatch k
and v
. But now the implementation body can proceed to use k
and v
as a destructured discriminated union; check k
, and v
is narrowed.
And there you go. Everything is, in fact, safe (or as safe as reasonable... generics can be used unsafely sometimes), and the compiler doesn't complain. The trick is, again, this pair of call and implementation signatures:
function uncurriedFn<K extends Keys>(k: K, v: Values<K>): void;
function uncurriedFn(...[k, v]: { [K in Keys]: [k: K, v: Values<K>] }[Keys]) {}
In an ideal world the compiler would know that the signature <K extends Keys>(k: K, v: Values<K>) => void
and (...args: {[K in Keys]: [k: K, v: Values<K>]}[Keys]) => void
are compatible (modulo ms/TS#27808). But it doesn't know this, so you have to trick it by taking advantage of the looseness of overload statement checking.
Upvotes: 3