typescriptgenerics

Can you require functions to have matching arguments, if they're defined within an object within a record?


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!

Link to TS playground


Solution

  • 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'.
      },
    });