Home > Enterprise >  Is it possible to recursively map one JSON-like type to another in typescript?
Is it possible to recursively map one JSON-like type to another in typescript?

Time:06-02

Link to TS Playground

I have a function called genWizardDefaultState which is used to generate a default state for a form. It's recursive so that it can support nested forms. I'm trying to make a recursive generic type so that the function actually returns a useful, typed output.

The function itself takes a JSON-like object whose values can only be Args, and returns an object of the same structure whose values are Fields. Args are defined like this:

interface TextArg {
    fieldType: "text";
}

interface NumberArg {
    fieldType: "number";
}

interface SelectArg {
    fieldType: "select";
}

type Arg = TextArg | NumberArg | SelectArg;

And Fields are defined like this:

interface TextField {
    value: string;
    errors: string[];
}

interface NumberField {
    value: number;
    errors: string[];
}

interface SelectField {
    value: any;
    label: string;
    errors: string[];
}

The more general Field type is generic: it takes an Arg and returns a specific Field based on the fieldType property:

interface ArgToFieldMap {
    text: TextField;
    number: NumberField;
    select: SelectField;
    boolean: BooleanField;
    date: DateField;
}
type Field<T extends Arg> = ArgToFieldMap[T["fieldType"]];

This works for flat object inputs (as long as I use as const assertion on the input type, so that typescript actually infers fieldType to be the string literal instead of just string):

type FormFields<T extends Record<keyof T, Arg>> = {
    [Key in keyof T]: Field<T[Key]>;
};

For example, this snippet does return the right type!

const exampleArgsObject = {
    name: { fieldType: "text" }, 
    age: { fieldType: "number" }, 
    favoriteTowel: { fieldType: "select" },
    } as const;
type formStateType = FormFields<typeof exampleArgsObject>;

But I'm hitting a wall when it comes to making this type recursive. I think I need to:

  1. Tell FormFields that the properties of its input type are either Arg or Array<FormField>.
  2. Use a conditional type as the the mapped output properties, to distinguish between Arg and Array<FormField> input properties

This is what I've come up with so far:

type FormFieldsRecursive<T extends Record<keyof T, Arg | Array<FormFieldsRecursive<T>>>> = {
  [Key in keyof T]: T[Key] extends Arg ? Field<T[Key]> : 'idk what goes here'
};

Any idea what I should be returning instead of "idk what goes here"? I know it should be some kind of generic type that returns an array of something, but I'm getting really confused. I'm starting to wonder if this is even possible!

CodePudding user response:

First, let's dispense with the generic constraint on FormFields<T> of the form T extends Record<keyof T, Arg | Array<FormFieldsRecursive<T>>>.

That version doesn't work because FormFieldsRecursive<T> is the output of your type transformation. If you we really want to give the input a name, it could look something like this:

type ArgsObj = { [k: string]: Arg | ReadonlyArray<ArgsObj> };

Here I'm saying that an ArgsObj has a string index signature so we don't care about its keys. (This will actually fail to match interfaces without explicit index signatures, as described in microsoft/TypeScript#15300). And then the properties are either Arg or an array of ArgsObj elements. Well, a read-only array, since you are using const assertions and those produce read-only arrays. Regular arrays are subtypes of readonly arrays, so this doesn't limit anything.

So we can, if we want, define type FormFields<T extends ArgsObj> = ... so that you can't pass in a "bad" T.

But I'm not going to bother. It only makes things more complicated. If you actually have functions somewhere that need to be sure a type is of the ArgsObj type, you can put the constraint there.


Okay, so how can we write FormFields<T> in such a way as to work recursively? I find the following approach where we break the operation up into two types to be easiest. One type, FormFields<T>, maps the entire object type, while FormField<T> will map a single property of that object type. At its core, it looks like this:

type FormFields<T> = {
  [K in keyof T]: FormField<T[K]>
}

type FormField<T> =
  T extends Arg ? Field<T> : { [I in keyof T]: FormFields<T[I]> };

So FormFields<T> is a simple mapped type which maps each property of T with FormField<>. And FormField<T> is a conditional type that checks to see if the property is an Arg. If so, it does Field<T> as before. If not, then we know it must be an array type holding types appropriate for FormFields<>, in which case we use the support for mapping arrays/tuples to arrays/tuples with mapped types to turn each element of T at numeric-like index I to FormFields<T[I]>.

We can check that this works:

const exampleArgsObjectRecursive = {
  name: { fieldType: "text" },
  age: { fieldType: "number" },
  favTowel: { fieldType: "select" },
  likesKanye: { fieldType: "boolean" },
  subForms: [
    {
      shoeColor: { fieldType: "text" },
    },
    {
      shoeColor: { fieldType: "text" },
    },
  ]
} as const;

type GeneratedRecursiveExampleFieldsType = FormFields<typeof exampleArgsObjectRecursive>;
/* type GeneratedRecursiveExampleFieldsType = {
  readonly name: TextField;
  readonly age: NumberField;
  readonly favTowel: SelectField;
  readonly likesKanye: BooleanField;
  readonly subForms: readonly [FormFields<{
      readonly shoeColor: {
          readonly fieldType: "text";
      };
  }>, FormFields<{
      readonly shoeColor: {
          readonly fieldType: "text";
      };
  }>];
 } */

So that looks good, except that the type display leaves things in terms of FormFields<>. If we want the compiler to actually expand out the type fully, we can use the approach shown in this other SO question of taking the mapped type and doing an extra infer and mapped type. The general form is we take a type XXX and convert it to XXX extends infer U ? { [K in keyof U]: U[K] } : never. Like this:

type FormFields<T> = {
  [K in keyof T]: FormField<T[K]>
} extends infer U ? { [K in keyof U]: U[K] } : never;

And now we get

type GeneratedRecursiveExampleFieldsType = FormFields<typeof exampleArgsObjectRecursive>;
/* type GeneratedRecursiveExampleFieldsType = {
     readonly name: TextField;
     readonly age: NumberField;
     readonly favTowel: SelectField;
     readonly likesKanye: BooleanField;
     readonly subForms: readonly [{
         readonly shoeColor: TextField;
     }, {
         readonly shoeColor: TextField;
     }];
} */

As desired!

Playground link to code

  • Related