Home > Mobile >  Dynamically type an object based on an array?
Dynamically type an object based on an array?

Time:09-06

type paramType = {type: string, value: string | number }

const PROFILE: Record<string, paramType> = {
  color: { type: 'string', value: 'red' },
  height: { type: 'number', value: 180 },
  weight: { type: 'number', value: 70 },
}

const constructObj = (selectedFields: string[]) => {
  return selectedFields.reduce((accum, cur) => {
            return { ...accum, [cur]: PROFILE[cur] };
        }, {} as Record<typeof selectedFields[number], paramType>);
}

const result = constructObj(['color']); // { color: {type: 'string', value: 'red' } }
// 1. result.color // Not throwing error, great
// 2. result.xxx // Not throwing error, Not good

constructObj is a function that will dynamically return an object with keys defined in selectedFields. I'm facing an issue where result.xxx is not throwing error?

CodePudding user response:

A slightly different solution from what @ASDFGerte suggested. With this you don't need the as const when calling the array. Personally, think it looks cleaner.

type paramType = { type: string; value: string | number };

const PROFILE: Record<string, paramType> = {
  color: { type: "string", value: "red" },
  height: { type: "number", value: 180 },
  weight: { type: "number", value: 70 },
};

const constructObj = <T extends string>(
  selectedFields: T[]
): Record<T, paramType> => {
  return selectedFields.reduce((accum, cur) => {
    return { ...accum, [cur]: PROFILE[cur] };
  }, {} as Record<typeof selectedFields[number], paramType>);
};

const result = constructObj(["color"]); // { color: {type: 'string', value: 'red' } }
result.color; // Not throwing error, great
result.xxx; // Throwing error now

Playground link

CodePudding user response:

Addressing @Keith's comment on @nullptr's answer, we can instead use this magic to infer the complete type of selectedFields without as const and without the drawback that the result's value is not string | number.

However you do have to sacrifice a little inside the body of the function, but hey, a little sacrifice for great typings sounds great to me.

The core of this solution is this interesting definition:

type Narrow<T> =
    | (T extends infer U ? U : never)
    | Extract<T, number | string | boolean | bigint | symbol | null | undefined | []>
    | ([T] extends [[]] ? [] : { [K in keyof T]: Narrow<T[K]> });

Essentially it's a bunch of "tests" to get TypeScript inferring what T is.

Here is my definition of constructObj:

const constructObj = <T>(selectedFields: Narrow<T>): T extends string[] ? {
  [K in T[number]]: K extends keyof typeof PROFILE_TYPE ? typeof PROFILE_TYPE[K] : never;
} : never => {
  return (selectedFields as string[]).reduce((accum, cur) => {
    return { ...accum, [cur]: PROFILE[cur] };
  }, {} as ReturnType<typeof constructObj<T>>);
}

We use Narrow to narrow down T. That means when we call it like this:

constructObj(["color"]);

T will be ["color"], not string[]! Of course once we have the tuple, we can iterate over the elements with K in T[number], and that's our result done.

You'll notice that there is PROFILE_TYPE now instead of PROFILE. What's that?

I changed PROFILE to this:

const PROFILE_TYPE = {
  color: { type: 'string', value: 'red' },
  height: { type: 'number', value: 180 },
  weight: { type: 'number', value: 70 },
};

const PROFILE = PROFILE_TYPE as Record<string, paramType>;

So you'll have your original PROFILE, but you also have the original that you can use for typings. The best of both worlds.

And this works amazingly.


Still confused about Narrow?

The way I have used it above is essentially equivalent to the following:

const constructObj = <T>(selectedFields: [T] extends [[]] ? [] : { [K in keyof T]: Extract<T[K], string> }): T extends string[] ? {

Yeah I don't think it's nice to read or maintain either. But again, what it does, is it tests T is an empty array with [T] extends [[]], then if it isn't, it "loops" over the tuple (because looping over it makes TypeScript infer it as a tuple for some reason) and then uses Extract<T[K], string> to further get TypeScript to infer each individual string as a literal.

  • Related