In a record of objects where each object has a fn
function and a genSetKey
function:
const cacheableFunctions = [
getUserProfile: {
fn: async (id: string) => {},
genSetKey: (id: string) => id,
},
getUserSchedule: {
fn: async (id: string, day: Date) => {},
genSetKey: (userId: string, day: Date) => userId + day.toDateString()
}
]
(The objects are called "invalidators" in the upcoming code snippets, since the goal of this project is to help invalidate old cache entries)
I want to require each genSetKey
's arguments to match its fn
's arguments.
This is what I tried at first:
type invalidator<TArgs extends unknown[]> = {
fn: (...args: TArgs) => any;
genSetKey: (...args: TArgs) => string;
};
type invalidatorRecord<T extends Record<string, unknown>> = {
[K in keyof T]: T[K] extends invalidator<infer U> ? invalidator<U> : never;
};
This works if you specify the types explicitly, but it doesn't work if you try to infer them:
const explicitExample: invalidatorRecord<{
updateName: invalidator<[string]>;
updateAge: invalidator<[number, Date]>;
}> = {
updateName: {
fn: (name: string) => {/* some biz logic */},
genSetKey: (name: string) => `name:${name}`,
},
updateAge: {
fn: (age: number, date) => {/* some biz logic */},
genSetKey: (age: string, date) => `age:${age}`,
// ^ correctly shows type error for wrong argument type
// (Type '(age: string) => string' is not assignable to type '(args_0: number) => string')
},
};
// little utilty to test inference
const makeInvalidatorRecord = <T extends Record<string, unknown>>(
invalidatorObj: invalidatorRecord<T>,
) => invalidatorObj;
const inferredExample = makeInvalidatorRecord({
updateName: {
// ^ Type '{ fn: (name: string) => void; genSetKey: (name: string) => string; }' is not assignable to type 'never'.
fn: (name: string) => {},
genSetKey: (name: string) => `name:${name}`,
},
updateAge: {
// ^ Type '{ fn: (name: string) => void; genSetKey: (name: string) => string; }' is not assignable to type 'never'.
fn: (age: number) => {},
genSetKey: (age: number) => `age:${age}`,
},
});
It seems like type of each invalidator is being inferred as never
, which makes me think that they're failing the T[K] extends invalidator<infer U> ?
conditional. But they seemingly match the shape of the invalidator
type!
You can help out type inference by tweaking makeInvalidatorObj
to be type-parameterized over the invalidators' argument types. This is easier to explain in code than prose.
Instead of this:
type invalidatorObj<T extends Record<string, unknown>> = {
[K in keyof T]: T[K] extends invalidator<infer U> ? invalidator<U> : never;
};
You could use a type like this:
type invalidatorObjFromArgs<T extends Record<string, unknown[]>> = {
[K in keyof T]: invalidator<T[K]>
};
And then have the type parameter of makeInvalidatorObj
be a Record
whose property values are argument tuples rather than entire invalidators.
Additionally, if you want an immediate type error on mismatched argument types (rather than reducing them to never
), you can apply NoInfer
to one of the TArgs
in invalidator
:
type invalidator<TArgs extends unknown[]> = {
fn: (...args: TArgs) => any;
genSetKey: (...args: NoInfer<TArgs>) => string;
};
The above tells TypeScript to only look at fn
's parameters during inference and complain if genSetKey
's don't match, rather than trying to infer a type that will satisfy both parameter lists.
Here's a complete example:
type invalidator<TArgs extends unknown[]> = {
fn: (...args: TArgs) => any;
genSetKey: (...args: NoInfer<TArgs>) => string;
};
type invalidatorObjFromArgs<T extends Record<string, unknown[]>> = {
[K in keyof T]: invalidator<T[K]>
};
const makeInvalidatorObj = <T extends Record<string, unknown[]>>(
invalidatorObj: invalidatorObjFromArgs<T>,
) => invalidatorObj;
const inferredExample = makeInvalidatorObj({
updateName: {
fn: (name: string) => {},
genSetKey: (name) => `name:${name}`,
// ^? - (parameter) name: string
},
updateAge: {
fn: (age: number) => {},
genSetKey: (age) => `age:${age}`,
// ^? - (parameter) age: number
},
updateSomethingElse: {
fn: (somethingElse: boolean) => {},
genSetKey: (oops: Date) => `oops:${oops}`,
// ^^^^^^^^^
// Type '(oops: Date) => string' is not assignable to type '(...args: NoInfer<[somethingElse: boolean]>) => string'.
// Types of parameters 'oops' and 'args' are incompatible.
// Type '[somethingElse: boolean]' is not assignable to type '[oops: Date]'.
// Type 'boolean' is not assignable to type 'Date'.
},
});