I've got a function trackEvent
that is typed correctly, but when using the Parameters utility, the type doesn't carry over in the same way.
Is there a way of using generics to correctly these arguments in an array format? (in the example alternativeTrackEvent
)
The idea would be to pass it through a React component like this
<Button eventParams={['car', 'drive', { city: 'london'}]} />
export type Events = {
car: {
drive: { city: string };
},
plane: {
fly: { country: string };
}
};
export type TrackEvent = <K extends keyof Events, E extends keyof Events[K], P extends Events[K][E]>(
tag: K,
name: E,
opts: P,
) => void;
const trackEvent: TrackEvent = (tag, name, opts) => console.log(tag, name, opts)
trackEvent('car', 'drive', { city: 'london'}) // works
trackEvent('plane', 'drive', { country: 'uk'}) // fails correctly
const alternativeTrackEvent: Parameters<TrackEvent> = ['car', 'drive', { city: 'london'}] // second argument is never
CodePudding user response:
TypeScript doesn't really support higher order generics of the sort that would be necessary to represent Parameters<TrackEvent>
without losing information. Perhaps if TypeScript directly supported existentially quantified generic types as requested in microsoft/TypeScript#14466, then Parameters<TrackEvent>
would be something like
type TrackEventParams = Parameters<TrackEvent>;
// the following is not valid TS syntax, don't try to use it
/* type TrackEventParams =
<∃K extends keyof Events, ∃E extends keyof Events[K], ∃P extends Events[K][E]>[
tag: keyof Events, name: never, opts: never
]
*/
where the fictitious generic syntax type X = <∃T> F<T>
means "a value is of type X
if it is there exists some type T
where the value is of type F<T>
". But such generics types are not part of TypeScript.
The Parameters<T>
utility is a conditional type and when it needs to match a generic function against something it will just specify the type arguments as their constraints.
So given your constraints K extends keyof Events, E extends keyof Events[K], P extends Events[K][E]
, Parameters<TrackEvent>
will behave as is TrackEvent
were defined like
type K = keyof Events; // "car" | "plane"
type E = keyof Events[K]; // never; it's keyof a union with no common keys
type P = Events[K][E]; // never
type TrackEvent = (tag: K, name: E, opts: P) => void;
// type TrackEvent = (tag: keyof Events, name: never, opts: never) => void
The reason why E
is never
is because Events["car"]
and Events["plane"]
share no common property names, and keyof
on a union type returns only key names known to exist on all members of the union.
Often when someone needs existentially quantified generics there's no equivalent specific type in TypeScript to use instead. You can think of an existential type as the union of the type for all possible instantiations of the generic (whereas TypeScript's generics, and those of most other languages, are the intersection of the type for all possible instantiations of the generic). When the generics are constrained to a type with an unlimited number of members (like string
), then, you could think of an existential type as an "infinite union", and TypeScript's unions are sadly only finite.
But in your case, you have K extends keyof Events
, a type that has only two values, "car"
and "plane"
, in its domain. And once you've chosen K
, then E
has only a finite number of values in its domain. So whereas in general existential types would be infinite unions, your example in particular can be represented by a finite union, and finite unions do exist in TypeScript.
Let's programmatically generate this union via a distributive object type (as coined in microsoft/TypeScript#47109), which is a mapped type into which one immediately indexes:
type TrackEventParams = { [K in keyof Events]:
{ [E in keyof Events[K]]:
[tag: K, name: E, opts: Events[K][E]]
}[keyof Events[K]]
}[keyof Events];
/* type TrackEventParams =
[tag: "car", name: "drive", opts: { city: string; }] |
[tag: "plane", name: "fly", opts: { country: string; }] */
That's a specific type you can use instead of Parameters<TrackEvent>
:
const alternativeTrackEvent: TrackEventParams =
['car', 'drive', { city: 'london' }]; // okay
In fact, depending on your use cases, you could even redefine TrackEvent
in terms of TrackEventParams
:
export type TrackEvent = (...[tag, name, opts]: TrackEventParams) => void;
const trackEvent: TrackEvent = (tag, name, opts) => console.log(tag, name, opts)
trackEvent('car', 'drive', { city: 'london' }) // works
trackEvent('plane', 'drive', { country: 'uk' }) // fails correctly