Search code examples
typescripttypescript-generics

Generic type for object created from deeply nested arrays of objects


I have the following object:

type Form = {
  fieldSets: Array<{
    rows: Array<{
      fields: Array<{ id: string; value: FieldValue; component: number }>;
    }>;
  }>;
};

type FieldValue = string | number | Date;

const form: Form = {
  fieldSets: [
    {
      rows: [
        {
          fields: [
            {
              id: 'f-1',
              value: 'value',
              component: 1,
            },
            {
              id: 'f-2',
              value: 123,
              component: 2,
            },
          ],
        },
      ],
    },
    {
      rows: [
        {
          fields: [
            {
              id: 'f-3',
              value: new Date(),
              component: 3,
            },
          ],
        },
      ],
    },
  ],
};

I also have the following function that accepts the above form object, and reduces it into an object containing each field ID (key) and the fields value (value):

const getFieldValues = (form: Form) => {
  const values = form.fieldSets.reduce((acc, fieldSet) => {
    fieldSet.rows.forEach(row => {
      row.fields.forEach(field => {
        acc[field.id] = field.value;
      });
    });

    return acc;
  }, {});

  return values;
};

const fieldValues = getFieldValues(form); // { 'f-1': 'value', 'f-2': 123, 'f-3': new Date() }

Each field value is 1 of 3 types, with the type being determined by the component property. I have a lookup type for that:

type ComponentValueLookup = {
  1: string;
  2: number;
  3: Date;
};

let value: ComponentValueLookup[2]; // number

How can I correctly type the return value of the function, so that each fields value is correctly typed using the ComponentValueLookup i.e:

type FieldValues<T extends Form> = {
  // Get deeply nested fields in form and type key/value using `id`/ComponentValueLookup
};

const fieldValues = getFieldValues(form);

fieldValues['f-1']; // string
fieldValues['f-2']; // number
fieldValues['f-3']; // date

Solution

  • First, in order for this to possibly work, you cannot annotate form as Form like

    const form: Form = { ⋯ };
    

    That throws away all information about the object literal other than the fact that it's a Form. You care about the specific literal types of the id and component properties inside that object literal. Don't annotate form at all; instead, use a const assertion to tell TypeScript it needs to care about literal types like "f-1" (instead of just string), and use the satisfies operator to make sure it's assignable to Form without widening it all the way to Form. It will look like this:

    const form = { ⋯ } as const satisfies Form;
    

    Now form is strongly typed enough so that the type contains all the information you need.


    Now we can define FieldValues<T> as follows:

    type FieldValues<T extends Form> = {
      [U in T["fieldSets"][number]["rows"][number]["fields"][number]
      as U["id"]]: ComponentValueLookup[U["component"] & keyof ComponentValueLookup];  
    } & {}
    

    This is essentially just a mapped type where we iterate a type parameter U over the union members of T["fieldSets"][number]["rows"][number]["fields"][number]. That's a deeply nested indexed access type. When you have a type like T[K1][K2][K3], you're saying "if I have a value t of type T, and values k1, k2, and k3 of the keylike types K1, K2, and K3, then T[K1][K2][K3] is the type of what you'd get if you read the property t[k1][k2][k3]. So if T is of type {a: {b: {c: X}}}, then T["a"]["b"]["c"] is X. And if T is an array type like Array<Y>, then T[number] is Y because if you index into an array with a numeric index, you'll get the element type (well, you might get undefined, but that is not included, as it's assumed you're only indexing within the bounds of the array).

    So what is T["fieldSets"][number]["rows"][number]["fields"][number]? Well, T is some generic subtype of Form. So let's say we have a value t of that type. Then T["fieldSets"][number]["rows"][number]["fields"][number] would be the value you get when you read the property t.fieldSets[i].rows[j].fields[k] where i, j, and k are appropriate numeric indices (of type number). So it's the union of all the elements of the fields arrays down inside T. It's a union of subtypes of { id: string; value: FieldValue; component: number }. And U will iterate over each member of that union.

    So now we know that U has an id and component property we care about, wer remap the keys with as to the id property like U["id"] and we map the values to the type you get when you look up the U["component"] property inside ComponentValueLookup. Conceptually this is just another indexed access type ComponentValueLookup[U["component"]], but there's nothing about T which requires the component property to be a key of ComponentValueLookup (you defined Form without reference to ComponentValueLookup; the component property can be any number whatsoever). So I intersect U["component"] with keyof ComponentValueLookup first. If U["component"] is such a key, then the intersection doesn't change it. Otherwise the intersection is reduced to the impossible never type and the value is also the never type. 🤷‍♂️ Not sure if you want never there or what, but that part is up to you and out of scope for the question as asked.

    Oh and that final intersection with the empty object {} is just to make the resulting type look like a plain object and maintain the FieldValues type alias; see Intersection with object in Prettify helper type?.


    The only thing left is to make getFieldValues() a generic function, since we need the output type to depend on the input type:

    const getFieldValues = <T extends Form>(form: T) => {
      const values = form.fieldSets.reduce(
        (acc, fieldSet) => {
          fieldSet.rows.forEach(row => {
            row.fields.forEach(field => {
              (acc as any)[field.id] = field.value;
            });
          });
          return acc;
        },
        {},
      );
      return values as FieldValues<T>;
    }
    

    I used some type assertions in there to avoid type errors inside the function; there's no chance TypeScript can follow the logic here (especially because nothing guarantees that value is of the ComponentValueLookup type you care about. Again, out of scope here) so an assertion is the best we can do.


    Let's test it:

    const fieldValues = getFieldValues(form);
    /* const fieldValues: {
        "f-1": string;
        "f-2": number;
        "f-3": Date;
    } */
    

    Looks good, that's the type you wanted.


    That's the answer to the question as asked. The following is technically off-topic, but if you want to guarantee that you use the components properly, you can use ComponentValueLookup to define Form like this:

    type Component = { [K in keyof ComponentValueLookup]:
      { id: string, value: ComponentValueLookup[K], component: K }
    }[keyof ComponentValueLookup]
    
    type Form = {
      fieldSets: Array<{
        rows: Array<{
          fields: Array<Component>;
        }>;
      }>;
    };
    

    And then your code will reject things like {id: "xxx", component: 5, value: "what is 5 supposed to be"} or {id: "yyy", component: 2, value: "this is supposed to be a number"}. And then ComponentValueLookup[U["component"]] will also work directly.

    Playground link to code