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:

    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