40

I would like to write a function that accepts an object with snake case keys and converts it to an object with camel case keys. What is the best way such a function could be typed in TypeScript, assuming we know the type of the input object, but want the solution to be generic.

type InputType = {
  snake_case_key_1: number,
  snake_case_key_2: string,
  ...
}

function snakeToCamelCase(object: T): U {
  ...
}

What is the best job that could be done to type T and U.

I would like U to be as narrowly typed as possible, and the type of U to be based on T ideally.

Ideally, if T is my example InputType I would like U to be typed as

{
  snakeCaseKey1: number,
  snakeCaseKey2: string,
  ...
}
mattnedrich
  • 7,577
  • 9
  • 39
  • 45
  • If you know the input type, why use generics at all? – Jared Smith Feb 17 '20 at 20:41
  • sorry, I guess I meant to say that I want to accept a known input type (not a generic object with `string` keys) - I would like to make the input generic though – mattnedrich Feb 17 '20 at 20:50
  • This is indeed impossible; Typescript has no type operators which act on string literal types to transform them into other string literal types, and therefore no way to transform arbitrary property names in this way. – kaya3 Feb 17 '20 at 21:16
  • @kaya3 bummer, looks like I'm stuck casting the response type then :( – mattnedrich Feb 17 '20 at 21:31
  • Does this answer your question? [Typescript generic to turn underscore object to camel case](https://stackoverflow.com/questions/57807009/typescript-generic-to-turn-underscore-object-to-camel-case) – Juliusz Gonera Jun 23 '21 at 21:37

5 Answers5

93

Solution

Playground

This is possible with template literal types in TypeScript 4.1 (see also snake_case):

type SnakeToCamelCase<S extends string> =
  S extends `${infer T}_${infer U}` ?
  `${T}${Capitalize<SnakeToCamelCase<U>>}` :
  S
type T11 = SnakeToCamelCase<"hello"> // "hello"
type T12 = SnakeToCamelCase<"hello_world"> // "helloWorld"
type T13 = SnakeToCamelCase<"hello_ts_world"> // "helloTsWorld"
type T14 = SnakeToCamelCase<"hello_world" | "foo_bar">// "helloWorld" | "fooBar"
type T15 = SnakeToCamelCase<string> // string
type T16 = SnakeToCamelCase<`the_answer_is_${N}`>//"theAnswerIs42" (type N = 42)

You then will be able to use key remapping in mapped types to construct a new record type:

type OutputType = {[K in keyof InputType as SnakeToCamelCase<K>]: InputType[K]}
/* 
  type OutputType = {
      snakeCaseKey1: number;
      snakeCaseKey2: string;
  }
*/

Extensions

Inversion type

type CamelToSnakeCase<S extends string> =
  S extends `${infer T}${infer U}` ?
  `${T extends Capitalize<T> ? "_" : ""}${Lowercase<T>}${CamelToSnakeCase<U>}` :
  S

type T21 = CamelToSnakeCase<"hello"> // "hello"
type T22 = CamelToSnakeCase<"helloWorld"> // "hello_world"
type T23 = CamelToSnakeCase<"helloTsWorld"> // "hello_ts_world"

Pascal case, Kebab case and inversions

Once you got above types, it is quite simple to convert between them and other cases by using intrinsic string types Capitalize and Uncapitalize:

type CamelToPascalCase<S extends string> = Capitalize<S>
type PascalToCamelCase<S extends string> = Uncapitalize<S>
type PascalToSnakeCase<S extends string> = CamelToSnakeCase<Uncapitalize<S>>
type SnakeToPascalCase<S extends string> = Capitalize<SnakeToCamelCase<S>>

For kebab case, replace _ of snake case type by -.

Convert nested properties

type SnakeToCamelCaseNested<T> = T extends object ? {
  [K in keyof T as SnakeToCamelCase<K & string>]: SnakeToCamelCaseNested<T[K]>
} : T

"Type instantiation is excessively deep and possibly infinite."

This error can happen with quite long strings. You can process multiple sub-terms in one go to limit type recursion to an acceptable range for the compiler. E.g. SnakeToCamelCaseXXL:

Playground

type SnakeToCamelCaseXXL<S extends string> =
  S extends `${infer T}_${infer U}_${infer V}` ?
  `${T}${Capitalize<U>}${Capitalize<SnakeToCamelCaseXXL<V>>}` :
  S extends `${infer T}_${infer U}` ?
  `${T}${Capitalize<SnakeToCamelCaseXXL<U>>}` :
  S

Note: In the first condition, T and U each infer one sub-term, while V infers the rest of the string.

Update: TS 4.5 will raise type instantiation depth limit from 50 to 100, so this compiler trick is not necessary with newer versions. For more complex cases, you now can also use tail recursive evaluation.

ford04
  • 66,267
  • 20
  • 199
  • 171
  • I liked reading this, and thanks for the answer. Could you guide me how to read this trick? I see it is accepting a generic `` and then it is checking if `string extends S`. It gives me mild headache. :) – Nishant Jan 12 '21 at 13:19
  • 1
    Sure - I originally had a different version, where the conditional type returned `never` instead of `S` in the false branch, so this clause would have ensured to return `string`. You have a good point though, that `string extends S` is not necessary with this version. I'll update answer - hope to have cleared the headache ;) – ford04 Jan 12 '21 at 13:47
  • your `snakeToCamel` has a slight error from what I can see - if I pass in `WHAT_EVER` I get back `whatEVER` instead of `whatEver` – ness-EE Feb 01 '21 at 18:24
  • 1
    @ness-EE SnakeToCamelCase expects you to put in a term with only lower case chars (snake_case) as described [here](https://stackoverflow.com/questions/17326185/what-are-the-different-kinds-of-cases). To transform `UPPER_CASE`, you can [change the false branch to `Lowercase`](https://tsplay.dev/VNVpnW). – ford04 Feb 01 '21 at 19:36
  • Is there a version that can convert keys for nested properties? – Calvin Feb 11 '21 at 17:19
  • 1
    @Calvin updated answer, take a look at "Convert nested properties" – ford04 Feb 11 '21 at 18:33
  • So amazing @ford04, thanks! Used this as a template for opening an issue in snakecase-keys to suggest they change the types to return objects with literal keys: https://github.com/bendrucker/snakecase-keys/issues/48 – Karl Horky Apr 30 '21 at 15:00
  • 2
    Isn't this going to camelcasify the attributes in the prototype of Array ? (such as push, pop, etc..) – Emmanuel Meric de Bellefon Jul 26 '21 at 09:14
  • @EmmanuelMericdeBellefon (missed your comment, sorry) Above mentioned type is a generic wrapper, hence will happily accept every type, you pass in. Instead of passing the `Array` container type itself, you probably rather want to provide the item type, create a subtype or conditionally filter properties out. – ford04 Nov 09 '21 at 13:51
  • There is one more issue in your answer: When converting nested props to snake case, if one of the props is an array you will get an object with keys _0, _1, etc... and all the array methods will be converted too, so you will get methods like: copy_within, find_index, flat_map. I think it might be possible to fix both to a degree, but it will probably get quite complex fast – kaan_a Apr 14 '22 at 15:33
  • @kaan_a yes, these are known issues with mapped types with tuples/arrays (like https://github.com/microsoft/TypeScript/issues/27995). Currently, I probably wouldn't use the nested type at all in production - or put more effort in resolving all edge cases. – ford04 Jun 12 '22 at 17:55
  • @EmmanuelMericdeBellefon here is how I fixed the issue with array methods: `type KeysToSnakeCase = T extends Array ? Array> : T extends object ? { [K in keyof T as CamelToSnakeCase]: KeysToSnakeCase } : T ` – gabberr Jan 27 '23 at 09:40
14

FWIW, I ran into some issues with the types from @ford04's answer. I found CamelCasedProperties and SnakeCasedProperties from https://github.com/sindresorhus/type-fest to work well so far.

Juliusz Gonera
  • 4,658
  • 5
  • 32
  • 35
  • 1
    These "type instantiation is excessively deep and possibly infinite." recursion depth errors can be solved by adding two lines in above solutions. You can give it a try with the playground example in the updated answer. – ford04 Jun 24 '21 at 06:33
  • 3
    I like the efforts that @ford04 shared in his answer but I would go with the library you shared! – parse Dec 29 '21 at 21:16
1

For the purposes of dunking on the accepted answer, and to solve a more general case: The code below shows probably the best that can be done at the moment. From keysToCamelCase() It splits word by the regular expression named at the top through types and the helper function, then changes those words to camel case. The helper deepMapKeys() actually implements the copy function. You can also pass in an explicit maximum depth to camel case (or multiple depths, or number to get the union of them).

// The root helper function.
function keysToCamelCase<T extends object, N extends number>(
    target: T,
    depth: N,
): CamelCaseProps<T, N> {
    return deepMapKeys(
        target,
        (key) => (typeof key == "string" ? toCamelCase(key) : key),
        depth,
    ) as any;
}

/**
 * Matches words under the pattern: [0-9]+|[A-Z]?[a-z]+|[A-Z]+(?![a-z])
 */
type Words<S extends string> = S extends S
    ? string extends S
        ? string[]
        : WordsAgg<S, []>
    : never;

type WordsAgg<S extends string, L extends string[]> = S extends ""
    ? L
    : S extends `${AsciiUpper}${AsciiLower}${string}`
    ? PascalWord<S, L>
    : S extends `${AsciiUpper}${string}`
    ? UpperWord<S, L>
    : S extends `${AsciiLower}${string}`
    ? CharsetWord<S, L, AsciiLower>
    : S extends `${AsciiDigit}${string}`
    ? CharsetWord<S, L, AsciiDigit>
    : S extends `${string}${infer Tail}`
    ? WordsAgg<Tail, L>
    : never;

type PascalWord<
    S extends string,
    L extends string[],
> = S extends `${infer Head extends AsciiUpper}${infer Tail extends `${AsciiLower}${string}`}`
    ? CharsetWord<Tail, L, AsciiLower, Head>
    : never;

type UpperWord<
    S extends string,
    L extends string[],
    W extends string = "",
> = S extends `${AsciiUpper}${AsciiLower}${string}`
    ? WordsAgg<S, [...L, W]>
    : S extends `${infer Next extends AsciiUpper}${infer Tail}`
    ? UpperWord<Tail, L, `${W}${Next}`>
    : WordsAgg<S, [...L, W]>;

type CharsetWord<
    S extends string,
    L extends string[],
    C extends string,
    W extends string = "",
> = S extends `${infer Next extends C}${infer Tail}`
    ? CharsetWord<Tail, L, C, `${W}${Next}`>
    : WordsAgg<S, [...L, W]>;

type AsciiDigit =
| "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";

type AsciiUpper =
 | "A" | "B" | "C" | "D" | "E" | "F" | "G" | "H" | "I" | "J" | "K" | "L"
 | "M" | "N" | "O" | "P" | "Q" | "R" | "S" | "T" | "U" | "V" | "W" | "X"
 | "Y" | "Z";

type AsciiLower = Lowercase<AsciiUpper>;

type PascalCase<S extends string> = S extends S
    ? string extends S
        ? string
        : ApplyCapitalize<Words<S>, "">
    : never;

type ApplyCapitalize<W extends string[], Acc extends string> = W extends []
    ? Acc
    : W extends [infer T extends string, ...infer U extends string[]]
    ? ApplyCapitalize<U, `${Acc}${Capitalize<Lowercase<T>>}`>
    : null;

type CamelCase<S extends string> = Uncapitalize<PascalCase<S>>;

type CamelCaseProps<T, N extends number = 1> = CamelCasePropsImpl<T, N, []>;

type CamelCasePropsImpl<
    T,
    N extends number,
    Stack extends unknown[],
> = N extends Stack["length"]
    ? T
    : T extends readonly (infer Elem)[]
    ? CamelCasePropsImpl<Elem, N, [unknown, ...Stack]>[]
    : T extends object
    ?
          | IfNumber<N, T>
          | {
                [K in keyof T as K extends string
                    ? CamelCase<K>
                    : K]: CamelCasePropsImpl<T[K], N, [unknown, ...Stack]>;
            }
    : T;

type IfNumber<N, T> = number extends N ? T : never;

const WORDS_PATTERN = /[0-9]+|[A-Z]?[a-z]+|[A-Z]+(?![a-z])/g;

function words<S extends string>(input: S): Words<S> {
    return (input.match(WORDS_PATTERN) ?? []) as any;
}

function uncapitalize<S extends string>(input: S): Uncapitalize<S> {
    return (input.slice(0, 1).toLowerCase() + input.slice(1)) as any;
}

function capitalize<S extends string>(input: S): Capitalize<S> {
    return (input.slice(0, 1).toUpperCase() + input.slice(1)) as any;
}

function toCamelCase<S extends string>(input: S): CamelCase<S> {
    return uncapitalize(
        words(input)
            .map((word) => capitalize(word))
            .join(""),
    ) as any;
}

function descriptorEntries(target: object) {
    return Object.entries(Object.getOwnPropertyDescriptors(target));
}

function isObject(value: unknown): value is object {
    return (typeof value == "object" && !!value) || typeof value == "function";
}

function copyPrototype(target: object) {
    const proto = Reflect.getPrototypeOf(target);
    const ctor = proto?.constructor;

    if (Array.isArray(target)) {
        return Reflect.construct(Array, [], ctor ?? Array);
    } else {
        return Reflect.construct(Object, [], ctor ?? Object);
    }
}

function deepMapKeys(
    target: object,
    callback: (key: string | symbol) => string | symbol,
    depth: number,
): object {
    const seen = new WeakMap();
    const root = copyPrototype(target);
    const stack: any[] = [{ src: target, dest: root, depth }];

    for (let top; (top = stack.pop()); ) {
        const { src, dest, depth } = top;

        for (const [key, descriptor] of descriptorEntries(src)) {
            const newKey = callback(key);
            const known = seen.get(descriptor.value);

            if (known) {
                descriptor.value = known;
            } else if (0 < depth && isObject(descriptor.value)) {
                const newSrc = descriptor.value;
                const newDest = copyPrototype(newSrc);

                descriptor.value = newDest;
                stack.push({
                    src: newSrc,
                    dest: newDest,
                    depth: depth - 1,
                });
            }

            Reflect.defineProperty(dest, newKey, descriptor);
        }
    }

    return root;
}
Hunter Kohler
  • 1,885
  • 1
  • 18
  • 23
0

Unfortunately, something like this is not possible. Typescript in its current form does not support type keys transformation/mapping.

elderapo
  • 370
  • 2
  • 12
  • 2
    There's nothing preventing you from manually iterating an object's properties, manipulating their keys, and writing them to another object in their manipulated form. However, that's not what the question is about -- it is asking about typing such a function, not about its implementation. – TimoStaudinger Feb 17 '20 at 20:39
  • Not true: the keyof operator exists. – Jared Smith Feb 17 '20 at 20:39
  • What I meant by transformation/mapping is transformation of types, not values. – elderapo Feb 17 '20 at 20:40
  • 1
    This is one of the few instances where I feel like TypeScript nudges you away from a "better" generic solution. Writing a snake to camel case function seems totally reasonable, but you can't do so without losing a substantial amount of type safety. Kind of unfortunate. – mattnedrich Feb 18 '20 at 02:54
  • @mattnedrich This is possible in TypeScript now. – ErikE Jan 17 '22 at 02:25
0

In case you are having trouble with primitives arrays being processed as well you can update the definition to

export type SnakeToCamelCaseNested<T> = T extends object
  ? T extends (infer U)[]
    ? U extends object
      ? { [K in keyof U as SnakeToCamelCase<K & string>]: SnakeToCamelCaseNested<U[K]> }[]
      : T
    : {
        [K in keyof T as SnakeToCamelCase<K & string>]: SnakeToCamelCaseNested<T[K]>;
      }
  : T;

Previous version (https://stackoverflow.com/a/65642944):

const form: SnakeToCamelCaseNested<{my_tags: string[]}> = ...

function checkCase(data: {myTags: string[]}){ ... }

checkCase(form)
 

Types of property 'tags' are incompatible. Type '{ length: number; to_string: {}; to_locale_string: {}; pop: {}; push: {}; concat: {}; join: {}; reverse: {}; shift: {}; slice: {}; sort: {}; splice: {}; unshift: {}; index_of: {}; last_index_of: {}; every: {}; ... 18 more ...; find_last_index: {}; } | undefined' is not assignable to type 'string[]

Abdul Malik
  • 541
  • 4
  • 16