2

I'm trying to define an array of fields for a form, where each item may have a different type. I've defined these types:

  interface FormData{
    value1:number
    value2:number|null
    value3:string
    value4:boolean
  }

  interface Field<T,K extends keyof T,TV=T[K]>{
    key:K&string
    value:TV
    validation?:(value:TV)=>boolean
  }

Defining an individual field works fine:

  const field:Field<FormData,'value1'>={
    key:'value1',
    value:1,
    validation(value:number):boolean{
      return value<3
    }
  }

But when defining an array for several items, like this:

  const fields:Field<FormData,keyof FormData>[]=[
    {
      key:'value1',
      value:1,
      validation(value:number):boolean{
        return value<3
      }
    },
    {
      key:'value3',
      value:'xxx',
      validation(value:string):boolean{
        return value!=='xxx'
      }
    }
  ]

TypeScript is:

  1. Allowing value be of any type, as long as it's number|null|string|undefined (eg a string for value1)
  2. Complaining about the validation() functions, since they don't accept number|null|string|undefined as a parameter

Is there a way to help TS infer the correct type of each item within the array? Ideally, I'd also would like to define the array as Field<FormData>[].

I'm using TypeScript 4.3.

hammurabi
  • 101
  • 3
  • 12
  • 1
    Does [this](https://tsplay.dev/m3zJjN) work for your use cases? I'd be much more inclined to make `Field` a union and not generic in `K extends keyof T`. – jcalz Jul 08 '21 at 15:21

1 Answers1

4

If you continue to make your type Field<T, K> generic in the key K, then you will indeed find it annoying to represent an array of Field<T, ??> for different keys K. There are ways to do it with mapped tuples, but really, this would require you to specify or have the compiler infer a particular array of key types. But you don't care about which keys are used, as long as they are all some actual keys.

This is a canonical use case for existentially quantified generics, which are not directly supported in TypeScript. (There is a request for this at microsoft/TypeScript#14466, but for now this is a missing feature.) TypeScript's generics, like most languages' generics, are universally quantified, which you can think of as being an infinite intersection of types. Existentially quantified generics, on the other hand, act as infinite union of types.

But wait, your FormData interface, like most object types, has a finite list of keys. You don't need an infinite union; you can just use a union! Meaning, make a union of your original Field<FormData, K> type, for each K in keyof FormData. Here's one way to define it:

  type Field<T> = { [K in Extract<keyof T, string>]-?: {
    key: K
    value: T[K],
    validation?: (value: T[K]) => boolean
  } }[Extract<keyof T, string>];

This builds a mapped type with the field type you want for each key K in keyof T, and then immediately indexes into that mapped type to get a union of its properties.

You can see that it makes the type you need:

  type FieldFormData = Field<FormData>;

  /* type FieldFormData = {
    key: "value1";
    value: number;
    validation?: ((value: number) => boolean) | undefined;
} | {
    key: "value2";
    value: number | null;
    validation?: ((value: number | null) => boolean) | undefined;
} | {
    key: "value3";
    value: string;
    validation?: ((value: string) => boolean) | undefined;
} | {
    ...;
} */

And now that there is no explicit K mentioned in the type, you can just use Array<Field<FormData>>:

const fields: Field<FormData>[] = [
  {
    key: 'value1',
    value: 1,
    validation(value: number): boolean {
      return value < 3
    }
  },
  {
    key: 'value3',
    value: 'xxx',
    validation(value: string): boolean {
      return value !== 'xxx'
    }
  }
]

and it will also catch errors:

const badFields: Field<FormData>[] = [
  { key: "value2", value: "oops" }, // error!
  { key: "value4", value: true } // okay
]

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360