6

I'm trying to write a function that takes as arguments two keys of a specific object inside an array. The keys are the following:

  • A string used to index a specific object
  • A function (from which I need the types of its parameters)

The array of objects is the following

const obj = [
  {
    name: "number",
    func: (foo: number) => foo,
  },
  {
    name: "string",
    func: (foo: string) => foo,
  },
  {
    name: "array",
    func: (foo: string[]) => foo,
  },
];

And this is my approach

type SingleObject = typeof obj[number];
type Name<T extends SingleObject> = T["name"];
type FuncParams<T extends SingleObject> = Parameters<T["func"]>;

const myFunc = async <T extends SingleObject>(
  name: Name<T>,
  params: FuncParams<T>
) => {
  // Do something
};

I thought that when calling myFunc passing "array" (for example) as the first parameter, it would identify the object in question and correctly assign the parameters of the other key (which in this case should be Parameters<(foo: string[] => foo)>)

This is clearly not the case, as I can do the following without any problems

myFunc("number", ["fo"]); // Should only allow a number as the second parameter

myFunc("string", [34]); // Should only allow a string as the second parameter

myFunc("array", [34]); // Should only allow an array of strings as the second parameter

Reference

1
  • 1
    Parameters<T["func"]> results in a type of number | string | string[] for params in this case, since all T["func"] does is aggregate all the types of the given array. Thats why it works. Now how to get it to work how you would like it to is an interesting problem. Commented Aug 16, 2021 at 15:14

1 Answer 1

6

By default, the compiler will infer the type of a string-valued property as string and not a string literal type like "number".

/* const obj: ({
    name: string; // oops
    func: (foo: number) => number;
} | {
    name: string; // oops
    func: (foo: string) => string;
} | {
    name: string; // oops
    func: (foo: string[]) => string[];
})[]    
*/

In order for your plan to possibly work, you need to convince the compiler that you care about the actual literal value of the name property of your elements of obj. The easiest way to do this is to use a const assertion on obj's initializer:

const obj = [
  {
    name: "number",
    func: (foo: number) => foo,
  },
  {
    name: "string",
    func: (foo: string) => foo,
  },
  {
    name: "array",
    func: (foo: string[]) => foo,
  },
] as const; // <-- const assertion

And now the type of obj is:

/* const obj: readonly [{
    readonly name: "number";
    readonly func: (foo: number) => number;
}, {
    readonly name: "string";
    readonly func: (foo: string) => string;
}, {
    readonly name: "array";
    readonly func: (foo: string[]) => string[];
}] */

where the name fields are the required types "number", "string", and "array".


Next, the best way to have the compiler infer the type of a generic type parameter is to give the compiler a value of that type from which to infer it. That is, inferring T from a value of type T is straightforward, but inferring T from a value of type T["name"] is not.

So my suggestion is something like this:

/* type NameToParamsMap = {
    number: [foo: number];
    string: [foo: string];
    array: [foo: string[]];
} */

const myFunc = async <K extends keyof NameToParamsMap>(
  name: K,
  params: NameToParamsMap[K]
) => {
  // Do something
};

The type NameToParamsMap is a helper object type whose keys are the name fields from obj elements, and whose properties are the parameter lists from the associated func field. Then myFunc can infer the generic type parameter K from the value of the name parameter passed in. Once K is properly inferred, the compiler can check the params parameter to make sure it matches.


Of course you don't want to write NameToParamsMap by hand; you can make the compiler compute it from typeof obj:

type NameToParamsMap = { 
  [T in typeof obj[number] as T["name"]]: Parameters<T["func"]> }

Here I'm using key remapping to iterate over each element T of typeof obj[number], and using T["name"] and T["func"] to produce the keys and values of the object.


Let's make sure it works:

myFunc("number", [1]); // okay
myFunc("number", ["oops"]) // error
// -------------> ~~~~~~
// Type 'string' is not assignable to type 'number'

myFunc("string", [34]); // error
myFunc("array", [34]); // error

Looks good!

Playground link to code

Sign up to request clarification or add additional context in comments.

1 Comment

This is awesome, couldn't ask for a more detailed and comprehensive answer. Learned a ton from your examples and links to documentation. Thank you so much!!

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.