Search code examples
typescripttypestype-inferenceconditional-types

Infer multiple possible types in passed array (spread operator)


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

enter image description here

Any idea how can i properly map the value types for each Field type passed as an argument?


Solution

  • 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