I hope this makes sense.
I'm trying to write a function that takes an object where each key equals a function and returns a function who's rest argument(s) are a translation of that object to [a key of the object, and the params of that key's function]
export const useConfig =
<T extends Record<string, (...a: any) => null>>(features: T) =>
<O extends T, K extends keyof O>(...args: [K, ...Parameters<O[K]>][]): void =>
void 0;
const config = useConfig({
attr: (prop: 'id', val: string) => null,
style: (prop: 'font', val: 'arial') => null,
});
This const t2 = config(['attr', 'font','']);
shouldn't work, it should:
Any help would be much appreciated. I feel like I'm just not getting something fundamental about the type system.
In what follows I'll call the operation in question, turning a key K
(which extends keyof T
for some suitable T
whose values are all function types) into the tuple [K, ...Parameters<T[K]>]
, "parameterizing" K
, or a "parameterization" of K
.
Conceptually you want the return type of useConfig
to be a function which accepts a variadic number of arguments, where each argument is a parameterization of some key in keyof T
. You don't really want to know which argument is a parameterization of which key, just that each one corresponds to some key. This use of "some" is indeed a hint that the kind of generic quantification you'd need here is existential instead of the "normal" universal quantification. You can think of normal, universal generics as intersections over every acceptable type, while existential generics are unions over every acceptable type.
And here, since "every acceptable type" is just the single members of keyof T
, then you can represent this union directly. All you want to do is distribute the parameterization operation over the union in K
to make a new union.
If you want to distribute an operation over keylike types, you can use a distributive object type (as coined in microsoft/TypeScript#47109) where you make a mapped type and then immediately index into it. If you have a key set KS
and you want to distribute the operation F<K>
over it, you can write that like {[K in KS]: F<K>}[KS]
. In your case KS
is keyof T
and F<K>
is [K, ...Parameters<T[K]>]
. So you get this:
const useConfig =
<T extends Record<string, (...a: any) => null>>(features: T) =>
(...args: { [K in keyof T]-?: [K, ...Parameters<T[K]>] }[keyof T][]): void =>
void 0;
Let's see what happens when we call it:
const config = useConfig({
attr: (prop: 'id', val: string) => null,
style: (prop: 'font', val: 'arial') => null,
});
/* const config: (...args: (
["attr", "id", string] | ["style", "font", "arial"]
)[]) => void */
So this is exactly what you want. Each argument passed into config()
should be a tuple of type ["attr", "id", string]
or one of type ["style", "font", "arial"]
. And you'll get the type checking you care about:
const t = config(
["attr", "id", "abc"], // okay
["style", 'font', "arial"], // okay
["attr", "font", ""] // error!
//~~~~~~~~~~~~~~~~~~
);
Unfortunately that doesn't give you a great experience with IntelliSense and autocompletion. This isn't really a problem with the above solution, but a limitation or missing feature of TypeScript, which is requested at microsoft/TypeScript#38603.
We can work around this by having the compiler actually try to figure out which argument is a parameterization of which key. That means if we call
const t = config(
["attr", "id", "abc"],
["style", 'font', "arial"],
["attr", "font", "abc"]
);
the compiler needs to know "the first key is "attr"
, the second one is "style"
, and third one is "attr"
. If you think of this as a tuple of keys KS
, then here KS
is ["attr", "style", "attr"]
. And we need to represent the operation of turning the KS
tuple into a tuple of parameterizations of each key in the tuple. That is, we want to map the parameterization operation over the input tuple to get an output tuple.
That version looks like this:
const useConfig =
<T extends Record<string, (...a: any) => null>>(features: T) =>
<KS extends Array<keyof T>>(...args: {
[I in keyof KS]: [KS[I], ...Parameters<T[Extract<KS[I], keyof T>]>]
}): void =>
void 0;
It's a bit more involved; the compiler does map tuples to tuples with {[I in keyof KS]: F<I>}
acting only on the numeric-like indices I
, but it doesn't quite know that it's doing this inside the body of the mapped type, so it will balk if you treat KS[I]
as if it's keyof T
, (because "wHaT iF I
iS somE arrAy meTHod nAme LIke "push"
? see ms/TS#27995). We need to use the Extract<T, U>
utility type to convince the compiler that KS[I]
can be treated as if it's assignable to keyof T
.
Now when we call config()
:
const t = config(
["attr", "id", "abc"], // okay
["style", 'font', "arial"], // okay
["attr", "font", "abc"], error!
// -----> ~~~~ <---- error here
);
/* const config: <["attr", "style", "attr"]>(
args_0: ["attr", "id", string],
args_1: ["style", "font", "arial"],
args_2: ["attr", "id", string]) => void
*/
You can see that the compiler infers ["attr", "style", "attr"]
for KS
, and then is unhappy specifically about the invalid "font"
value in the args_2
argument.