Search code examples
typescript

How can I construct self-referencing type validation in TypeScript?


I'm trying to construct a type that ensures that everything registered in a requiredFields array is set within defaults.

I want defaults to throw type errors if it is missing any of the requiredFields’ names, and if its value doesn't align to the options for the corresponding item.

requiredFields is meant to be flexible. The entire object will be known statically—not evaluated at runtime, but I want to configure several different versions with the same overall type-logic that ensures I don't miss registering defaults.

const configuration: FormConfiguration = {
  defaults: { // should throw error for not having `numbers`
    letters: 'f', // should throw error because 'f' isn't an option
  },
  requiredFields: [
    {
      name: 'letters',
      options: ['a', 'b', 'c'],
    },
    {
      name: 'numbers',
      options: [1, 2, 3],
    },
  ],
}

I know I could construct defaults for form items in other ways, but this is merely an example for a much more complex type I'm attempting to create.

This is my attempt at creating types:

type Names = FormConfiguration['requiredFields'][number]['name']
// returning:
// type Names = string

type Options =
  FormConfiguration['requiredFields'][number]['options'][number]
// returning:
// type Options = string | number

type FormConfiguration = {
  requiredFields: Array<{
    name: string
    options: (string | number)[]
  }>
  defaults: Record<Names, Options>
}

Clearly, based on the output, there's no sense of self-referencing happening, nor an ability to get correct options per item.

This is doable somehow, right?


Solution

  • Specific TypeScript types like FormConfiguration can't really be "self-referencing" in the way you mean. If you want a type that depends on things (even itself) then you want the type to be generic like FormConfiguration<F>. (Well, there's also the polymorphic this type, which lets a type reference itself in a way similar to what you mean, but it's not really going to be helpful to you, since then you'd need to write explicit subtypes of FormConfiguration and... I won't digress further, it's not the way you want to go.)

    Anyway, let's write FormConfiguration<F> where F is the intended element type of the requiredFields array:

    interface FormConfiguration<F extends { name: string, options: any[] }> {
      defaults: { [T in F as T["name"]]: T["options"][number] };
      requiredFields: F[]
    }
    

    Here the defaults type is a key-remapped mapped type where each key is from the name property of F, and each value is one of the elements of the options property.


    Now, that type works, but it's annoying to use directly. Like, you could write this:

    const configuration: FormConfiguration<
      { name: "letters"; options: ["a", "b", "c"] } |
      { name: "numbers"; options: [1, 2, 3] }
    > = {
      defaults: {
        numbers: 1,
        letters: "b"
      },
      requiredFields: [
        {
          name: 'letters',
          options: ['a', 'b', 'c'],
        },
        {
          name: 'numbers',
          options: [1, 2, 3],
        },
      ],
    };
    

    and it would definitely catch the errors you care about, but it's redundant. It requires you write out requiredFields essentially twice; once as a type, and again as a value. It would be wonderful if you could get the compiler to infer the type argument for you, perhaps by writing something like

    const configuration: FormConfiguration<infer> = { ⋯ }
    

    but TypeScript doesn't support type argument inference in generic types, only in generic function calls. There's an open feature request at microsoft/TypeScript#32794 to support something like infer as a type argument, but until and unless it's adopted, we need to work around it.


    The standard workaround here is to provide a helper function which is generic and lets you infer the type argument you care about, and it returns its input. So instead of const configuration: FormConfiguration<infer> = { ⋯ };, you write const configuration = formConfiguration({ ⋯ });. It's basically the same thing, and it's not even all that different in terms of developer effort, but it's still a workaround.

    Anyway, here's the helper function:

    const formConfiguration = <const F extends { name: string, options: any[] }>(
      f: FormConfiguration<F>) => f;
    

    Note that I use a const type parameter so that F will be inferred with literal types for the name and options fields. Normally TypeScript would look at {name: "xyz"} and infer the type {name: string} for it. But you actually care about that "xyz", so a const type parameter helps us.


    So now, finally, let's test it:

    const configuration = formConfiguration({
      defaults: {
        numbers: 1,
        letters: "b"
      },
      requiredFields: [
        {
          name: 'letters',
          options: ['a', 'b', 'c'],
        },
        {
          name: 'numbers',
          options: [1, 2, 3],
        },
      ],
    }); // okay
    

    That compiles as desired, and the type of configuration is essentially the same FormConfiguration<{ ⋯ } | { ⋯ }> I wrote out manually above (although the const type parameter adds some readonly in there). And if you make mistakes, you get errors where you expect them:

    const configuration = formConfiguration({
      defaults: { // error! numbers is missing
        letters: "b"
      },
      requiredFields: [
        {
          name: 'letters',
          options: ['a', 'b', 'c'],
        },
        {
          name: 'numbers',
          options: [1, 2, 3],
        },
      ],
    });
    

    or

    const configuration = formConfiguration({
      defaults: {
        numbers: 1,
        letters: "f" // error! "f" is not assignable to "a"|"b"|"c"
      },
      requiredFields: [
        {
          name: 'letters',
          options: ['a', 'b', 'c'],
        },
        {
          name: 'numbers',
          options: [1, 2, 3],
        },
      ],
    });
    

    Looks good!

    Playground link to code