0

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?

Samuel Hulla
  • 6,617
  • 7
  • 36
  • 70
  • Is [this](https://tsplay.dev/WGRKXm) what you want? There are various discrepancies with your example code. Why do you use `field1` and `field2` to index into `form.fields`? Shouldn't it be `age` or `nickname` because that's what the field names are? Why are you accessing `value` on the field? Shouldn't the field be its value already? Not the field itself? – kelsny Oct 01 '22 at 16:50
  • This does not really make sense. Why are you trying to access `form.fields['field1']`. Why `'field1`? This is only the name of the variable but you are never passing this information to the function. Did you mean to write `form.fields['age']`? – Tobias S. Oct 01 '22 at 16:51
  • @TobiasS @caTs Yes, sorry. That was just oversight on my part when changing my original code to minimal reproducible example. It should be `form.fields['age']` instead. Edited my original question – Samuel Hulla Oct 01 '22 at 18:41
  • @SamuelHulla As @caTS [commented](https://stackoverflow.com/questions/73920034/infer-multiple-possible-types-in-passed-array-spread-operator#comment130520805_73920034), you must use a [const assertion](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-4.html#const-assertions) (`as const`) because TS does not [infer](https://www.typescriptlang.org/docs/handbook/type-inference.html) literal types from object property values. – jsejcksn Oct 01 '22 at 19:07
  • Does this answer your question? [TypeScript struggling with simple type inference for string literal](https://stackoverflow.com/questions/73456254/typescript-struggling-with-simple-type-inference-for-string-literal) – jsejcksn Oct 01 '22 at 19:10
  • @jsejcksn How would a `as const` assertion map the types on a mapped object? It's not like I can just pass `as const` to the default `{}` object in `.reduce`? And I do not know the exact value types from the mapping function. I fail to see how this would apply because least to my knowledge `as const` would make sense if i only knew the exact object structure in advance, which is not the case in this question. Could you post an example of what you mean? – Samuel Hulla Oct 01 '22 at 19:14
  • If you don't know the exact object structure in advance, then this is not possible... – kelsny Oct 01 '22 at 20:29
  • @caTS i know the exact object structure, but i do not know the type of the `value` property in each and every passed `...args` parameters – Samuel Hulla Oct 01 '22 at 20:43
  • 1
    [^](https://stackoverflow.com/questions/73920034/infer-multiple-possible-types-in-passed-array-spread-operator?noredirect=1#comment130522489_73920034) @SamuelHulla You can build a type using recursion and conditional inference like is being done [here](https://stackoverflow.com/a/70499768/438273). – jsejcksn Oct 01 '22 at 21:51
  • @jsejcksn Yup thanks a lot, I did not know type recursion is possible in typescript. That actually is super helpful, especially considering TS has no runtime on compile, so recursion is not that big of a deal. Still had some tinkering to do, but iltimately it was enough to guide me towards the correct answer – Samuel Hulla Oct 04 '22 at 08:43

1 Answers1

0

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

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
Samuel Hulla
  • 6,617
  • 7
  • 36
  • 70