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
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.