Search code examples
typescripttypestype-mapping

Mapping Types Recursively with an Irregularly Nested Type


I'm trying to map a theme of mine into a theme object of forms, something like this:

type Color1 = string & { __brand: "color1" }
type Color2 = string & { __brand: "color2" }

interface Theme {
  colors: {
    primary: Color1;
  };
  border: {
    radius: number;
  };
  fonts: {
    main: string;
  };
  components: {
    container: {
      padding: number
    },
    radioCheckbox: {
      active: Color1;
    };
  };
}

which should be transformed to

interface ThemeForm {
  colors: {
    primary: FormField<Color2>;
  };
  border: {
    radius: FormField<number>;
  };
  fonts: {
    main: FormField<boolean>;
  };
  components: {
    container: {
      padding: FormField<number>
    },
    radioCheckbox: {
      active: FormField<Color2>;
    };
  };
}

where

type FormTypes = string | number | boolean | undefined;

interface FormField<F extends FormTypes> {
  value: F;
  error: boolean;
  errorMsg: string;
}

If my Theme interface were flatter, I know the solution would look something like this:

interface FlatterTheme {
  radius: number;
  padding: number;
  ...
};

type FlatterFormTheme = {
  [K in keyof FlatterTheme]: FormField<FlatterTheme[K]>
}

However, my Theme interface is unfortunately is nested and with different depth levels, so mapping that type is a bit beyond me. Does anyone know how to do it, or if it really is possible to do in TS?


Solution

  • You could write a ToForm<T> utility type to transform Theme to ThemForm like this:

    type ToForm<T> =
      T extends Color1 ? FormField<Color2> :
      T extends string ? FormField<boolean> :
      T extends FormTypes ? FormField<T> :
      { [K in keyof T]: ToForm<T[K]> }
    

    This is a conditional type that maps:

    • Color1 to FormField<Color2>,
    • a string that isn't a Color1 to FormField<boolean>,
    • any other FormTypes T that isn't a string to FormField<T> (so number becomes FormField<number>), and crucially:
    • any other type (like an object type) to a recursive mapped type where each property has ToForm applied to it.

    Note that the order of the clauses is important here because your various cases aren't mutually exclusive. For example, if they were flipped around like T extends string ? FormField<boolean> : T extends Color1 ? FormField<Color2> : ⋯ then it would do the wrong thing because Color1 extends string.


    Let's test it out:

    type ThemeForm = ToForm<Theme>
    
    /*
    type ThemeForm = {
        colors: {
            primary: FormField<Color2>;
        };
        border: {
            radius: FormField<number>;
        };
        fonts: {
            main: FormField<boolean>;
        };
        components: {
            container: {
                padding: FormField<number>;
            };
            radioCheckbox: {
                active: FormField<Color2>;
            };
        };
    }
    */
    

    Looks good; it matches the desired ThemeForm from the question.

    Playground link to code