How to DRY two similar typescript interfaces with different key names

Viewed 83

I have the same interface in two different formats, a JSON format where the keys are separated by low dash and a javascript camelCase format:

JSON format:

interface MyJsonInterface {
  key_one: string;
  key_two: number;
}

interface MyInterface {
  keyOne: string;
  keyTwo: number;
}

I would like to prevent duplications and don't know the right way to do it. I checked out this question but the answer was not satisfactory, because I don't want the same keys to be available for both interfaces.

is there a different way?

3 Answers

Let's split this task into smaller sub tasks. First of all you need to write an utility type which will convert snake_case to camelCase. Let's focus on it.

See this:

type Separator = '_'
type Convert<Str extends string, Acc extends string = ''> =
  // Check if Str mathes the pattern string_string
  (Str extends `${infer Head}${Separator}${infer Tail}`
    // If yes, check whether it is a first call or not, because we don't want to capitalize for part of the string
    ? (Acc extends ''
      // This is a first call, because Acc is empty, hence first part should not be capitalized
      ? Convert<Tail, `${Acc}${Head}`>
      // This is not first call, hence Head should be capitalized
      : Convert<Tail, `${Acc}${Capitalize<Head>}`>)
    // This is the last call, because Str does not match the pattern
    : `${Acc}${Capitalize<Str>}`)

Now, we can just iterate through the interface and replace each key with converted:

type Builder<T> = {
  [Prop in keyof T as Convert<Prop & string>]: T[Prop]
}

// {
//     oneTwoThreeFourthFiveSixSevenEightNineTen: "hello";
// }
type Result = Builder<{
  one_two_three_fourth_five_six_seven_eight_nine_ten: 'hello'
}>

Playground with full code

Make it vice versa:

type Separator = '_'

type IsChar<Char extends string> = Uppercase<Char> extends Lowercase<Char> ? false : true;

type IsCapitalized<Char extends string> =
  IsChar<Char> extends true
  ? Uppercase<Char> extends Char
  ? true
  : false
  : false

type Replace<Char extends string> =
  IsCapitalized<Char> extends true
  ? `${Separator}${Lowercase<Char>}`
  : Char

type Result2 = Replace<'A'>

type CamelToSnake<
  Str extends string,
  Acc extends string = ''
  > =
  Str extends `${infer Char}${infer Rest}` ? CamelToSnake<Rest, `${Acc}${Replace<Char>}`> : Acc

// type Result = "foo_bar_baz"
type Result = CamelToSnake<'fooBarBaz'>

Playground

The following would do the trick.

interface MyJsonInterface {
  key_one: string;
  key_two: number;
  key_three_other: number;
  key_four_with_another: number;
}

type PropMapping<T> =
  T extends `${infer ST}_${infer ND}_${infer RD}_${infer TH}`
  ? `${ST}${Capitalize<ND>}${Capitalize<RD>}${Capitalize<TH>}`
  : T extends `${infer ST}_${infer ND}_${infer RD}`
  ? `${ST}${Capitalize<ND>}${Capitalize<RD>}`
  : T extends `${infer ST}_${infer ND}`
  ? `${ST}${Capitalize<ND>}`
  : never

type MyInterface = {
  [K in keyof MyJsonInterface as PropMapping<K>]: MyJsonInterface[K]
}

enter image description here

Using the ideas of the intuitive and readable answer above by @lepsch, here's a version that handles any amount of lodashs/subscripts.

Caviat: I think, it's up to 1000 lodashs which is the inbuilt tail-recursion depth limit of TypeScript since version 4.5. I tested with 1000 and it worksed. Weirdly enough though, using 1001 lodashs did not result in an error ('possibly infinite recursion depth') but the key just vanished from MyInterface.

Here's a link to the corresponding Typescript Playground.

interface MyJsonInterface {
  key_one: string;
  key_two: number;
  key_three_other: number;
  key_four_with_another: number;
  key_with_many_more_lodashs_bla_bla_bla_bla_bla: boolean;
  // quirk: keys starting with _ will be turned into upper case (-> HelloStuff)
  __hello__stuff: number
}

type SubstringUntilLodash<T> = T extends `${infer U}_${infer P}` ? U : never; 
type SubstringAfterLodash<T> = T extends `${infer U}_${infer P}` ? P : never; 
type ContainsLodash<T> = SubstringAfterLodash<T> extends '' ? false : true;

type PropMappingRecursive<T extends string, Original extends string = T, Processed extends string = "", Result extends string = ""> =
  // recursion anchor 1: if no lodash contained in T
  ContainsLodash<T> extends false ? 
  // then return T if it was the original string (handles that the first latter is small), else the previous result plus capitalized T.
  T extends Original ? T : `${Result}${Capitalize<T>}` :
  // Making sure the first substring starts with a small letter, and dorecursive call
  Processed extends '' ? PropMappingRecursive<SubstringAfterLodash<T>, Original, `${Processed}_${SubstringUntilLodash<T>}`, `${SubstringUntilLodash<T>}`> :
  // else capitalize and do recursive call
  PropMappingRecursive<SubstringAfterLodash<T>, Original, `${Processed}_${SubstringUntilLodash<T>}`, `${Result}${Capitalize<SubstringUntilLodash<T>>}`>

type MyInterface = {
  [K in keyof MyJsonInterface as PropMappingRecursive<K>]: MyJsonInterface[K]
}

enter image description here

Related