I'm looking for a way to infer type for each and every spread argument of my type function.
Let's say I have the two fields with the following definition.
type Field<T> = { value: T, name: string }
const field1 = { value: 12, name: 'age' }
const field2 = { value: 'foo', name: 'nickname' }
and I want to be able to pass these fields as my spread arguments to the following function, that would be called in a following fashion
useForm('registration', field1, field2)
So I tried using a conditional type inferrence as per the official docs, which did solve the issue for the most part
type InferredFields<T> = T extends { value: infer V }[]
? Record<string, Field<V>>
: never
const useForm = <T extends Field<unknown>[]>(name: string, ...args: T) => {
const fields: InferredFields<T> = args.reduce(
(res, field) => ({
...res,
[field.name]: field.value,
}),
{} as InferredFields<T>,
)
return {
name,
fields
}
}
const form = useForm('bar', field1, field2)
My only issue is, it cannot properly discriminate the union produced by the inferred value of the passed array generic based on which value we are using.
type FieldValue<T> = T extends { value: infer V } ? V : never
// This is an issue since the return type of form is
// { fields: Record<string, string | number> }
// instead of the properly inferred value type
const v1: FieldValue<typeof field1> = form.fields['age'].value // error
const v2: FieldValue<typeof field2> = form.fields['nickname'].value // error
Any idea how can i properly map the value types for each Field
type passed as an argument?
Alright, this is bit tricky because it requires a type recursion.
Essentially I created the following type:
export type FormFields<T extends readonly unknown[]> = T extends readonly [
infer FieldType,
...infer Rest,
]
? FieldType extends { value: infer V }
? { [key: string]: Field<V> } & FormFields<Rest>
: never
: never
It's a bit difficult to explain on first glance and easier to comprehend if you use it, but I'll try my best.
Where we pass a generic T
in a shape of unknown[]
. We extract always the first value (type) FieldType
of the array and the inferred rest parameter Rest
.
We then match the object and infer it's value V
in a form of another generic. Now we can finally shape our object that will be of type Field<V>
where we are passing the value type V
to our type Field
from the question. Finally we intersect it &
with a recursive call to our type FormFields
in a shape of FormField<Rest>
where we pass the remaining arguments of the array, extracting their types in a FIFO style recursive algorithm.
Now we can finally use the type in our useForm
function.
const useForm = <T extends Array<Field<unknown>>>(
name: string
...addedFields: T
) => addedFields.reduce(
(fields, field) => ({
...fields,
[field.name]: field,
}),
{} as FormFields<T>
) as FormFields<T>
Technically it's not ideal, because if you were to try to assign it to a constant, i.e.
const fields: FormFields<T>
then you would get a type error that Field<unknown>
is not assignable to FormFields<T>
, because of our constraint in the defined useForm
which specifies T
as Array<Field<unknown>>
. that's because the FormFields
type generic T
must be instantiated to unknown[]
other the inferred type would never match the constraint of Field
and then on the other hand the useForm
requires a constraint to Array<Field<unknown>>
otherwise the user could just pass any array into the rest arguments. So it's kind of a compromised solution, but ultimately the final assertion with as
counts as "good enough" for me as it maintains the desired object shape with all the required types.
Now you get correct types from the recursive infer:
form.fields['age'] // Field<12>
form.fields['nickname'] // Field<'foo'>
// bonus below (unnecessary for answer):
// if you need to convert Field<'foo'> to Field<string> for let's say
// an onChange handler, which cant have "as const" styled value
// for arguments, its just a matter of a simple helper
export type InferredToPrimitive<T> = T extends string
? string
: T extends number
? number
: T extends boolean
? boolean
: T extends Record<string, unknown>
? Record<string, unknown>
: T extends Array<unknown>
? Array<unknown>
: T