Search code examples
typescript

Use function first parameter value as key for accessing a type property


I have a function that accepts two parameters, the first one is the key I want to access from a given type and the second one should be the values that this given type and key have, for example, given the following type:

type Example = {
  letter: 'a' | 'b' | 'c';
  number: 1 | 2 | 3;
}

I want these scenarios to be VALID:

myFunction<Example>('letter', 'a');
myFunction<Example>('letter', 'c');
myFunction<Example>('number', 1);
myFunction<Example>('number', 2);

And these scenarios to be INVALID:

myFunction<Example>('letter', 1);
myFunction<Example>('letter', 2);
myFunction<Example>('number', 'b');
myFunction<Example>('number', 'c');

Currently I have something that looks similar to this:

type MyProperties = {
  size: 'small' | 'medium' | 'large';
  type: 'a' | 'b' | 'c';
}

/**
 * The second parameter type is not valid because it is accepting all the
 * possible values 'small', 'medium', 'large', 'a', 'b' or 'c' even when
 * the key is 'size' for example.
 */
function testing<P>( key: keyof P, value: P[typeof key] ) {
  //
}

// The current scenario gives me the following:

testing<MyProperties>('size', 'small');      // OK
testing<MyProperties>('size', 'medium');     // OK
testing<MyProperties>('size', 'large');      // OK
testing<MyProperties>('size', 'a');          // OK.
testing<MyProperties>('size', 'b');          // OK.
testing<MyProperties>('size', 'c');          // OK.
testing<MyProperties>('size', 'something');  // BAD.

// But what I really want to achieve is:

testing<MyProperties>('size', 'small');      // OK
testing<MyProperties>('size', 'medium');     // OK
testing<MyProperties>('size', 'large');      // OK
testing<MyProperties>('size', 'a');          // BAD because 'a' is not a "size" valid value even tho is valid for P[typeof key].
testing<MyProperties>('size', 'b');          // BAD because 'b' is not a "size" valid value even tho is valid for P[typeof key].
testing<MyProperties>('size', 'c');          // BAD because 'c' is not a "size" valid value even tho is valid for P[typeof key].
testing<MyProperties>('size', 'something');  // BAD because 'something' is not a "size" valid value.

Is this even possible?


Solution

  • TypeScript currently lacks the ability to have partial generic type argument inference, as requested in microsoft/TypeScript#26242. So while conceptually it would be nice to write

    declare function myFunction<T extends object, K extends keyof T>(k: K, v: T[K]): void;
    

    you can't call that in a way that lets you manually specify T but has the compiler infer K:

    myFunction<Example>('letter', 'a'); // error!
    //         ~~~~~~~
    // Expected 2 type arguments, but got 1.(2558)
    

    You could call it by manually specifying both T and K:

    myFunction<Example, 'letter'>('letter', "a"); // okay
    

    but that's annoying/redundant.


    Until and unless microsoft/TypeScript#26242, you'll need to work around it. In general, people work around that by currying:

    declare function myFunction<T extends object>(): <K extends keyof T>(k: K, v: T[K]) => void;
    myFunction<Example>()('letter', "a"); // okay
    

    This works but involves an extra function call you don't want. Still, if you need the function to still be generic in K after specifying T, you have to do something like this.


    But maybe you don't need the function to be generic in K. Since K is only meant to be one of a union of types, you can try representing the function's parameters as a union of tuple-typed rest parameters:

    type ArgsForMyFunction<T> = { [K in keyof T]: [k: K, v: T[K]] }[keyof T];
    declare function myFunction<T extends object>(...[k, v]: ArgsForMyFunction<T>): void;
    

    Here, the ArgsForMyFunction<T> is a distributive object type as coined in microsoft/TypeScript#47109. That's where you write a mapped type over a set of keys and then index into it with the same set of keys, producing a union of properties. If you have a type function like F<K>, then type G<K> = {[P in K]: F<P>}[K] distributes F over unions in K, such that G<K1 | K2> is F<K1> | F<K2>.

    So, in ArgsForMyFunction<T>, the parameter list tuple [k: K, v: T[K]] is distributed over the union in keyof T. Like so:

    type Example = {
      letter: 'a' | 'b' | 'c';
      number: 1 | 2 | 3;
    }    
    type ExampleArgs = ArgsForMyFunction<Example>;
    // type ExampleArgs = [k: "number", v: 2 | 1 | 3] | [k: "letter", v: "a" | "b" | "c"]
    
    type TestArgs = ArgsForMyFunction<{ a: string, b: number, c: boolean }>;
    // type TestArgs = [k: "a", v: string] | [k: "b", v: number] | [k: "c", v: boolean]
    

    And that means myFunction() is now only generic in T and you don't need currying:

    myFunction<Example>('letter', 'a'); // okay
    myFunction<Example>('letter', 'c'); // okay
    myFunction<Example>('number', 1); // okay
    myFunction<Example>('number', 2); // okay
    
    myFunction<Example>('letter', 1); // error
    myFunction<Example>('letter', 2); // error
    myFunction<Example>('number', 'b'); // error
    myFunction<Example>('number', 'c'); // error
    

    which is what you wanted.

    Playground link to code