51

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,
  ...
}
5
  • If you know the input type, why use generics at all? Commented Feb 17, 2020 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 Commented Feb 17, 2020 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. Commented Feb 17, 2020 at 21:16
  • @kaya3 bummer, looks like I'm stuck casting the response type then :( Commented Feb 17, 2020 at 21:31
  • Does this answer your question? Typescript generic to turn underscore object to camel case Commented Jun 23, 2021 at 21:37

6 Answers 6

130

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.

Sign up to request clarification or add additional context in comments.

12 Comments

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 ;)
@ness-EE SnakeToCamelCase expects you to put in a term with only lower case chars (snake_case) as described here. To transform UPPER_CASE, you can change the false branch to Lowercase<S>.
@Calvin updated answer, take a look at "Convert nested properties"
Isn't this going to camelcasify the attributes in the prototype of Array ? (such as push, pop, etc..)
@EmmanuelMericdeBellefon here is how I fixed the issue with array methods: type KeysToSnakeCase<T> = T extends Array<string> ? Array<CamelToSnakeCase<string>> : T extends object ? { [K in keyof T as CamelToSnakeCase<string & K>]: KeysToSnakeCase<T[K]> } : T
|
17

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.

2 Comments

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.
I like the efforts that @ford04 shared in his answer but I would go with the library you shared!
2

Based on @ford04 response, here is a version of SnakeCase that handle first uppercase character and value that already have underscore in it:

/**
 * Helper function that removes the first underscore from a string
 */
type RemoveFirstUnderscore<S> = S extends `_${infer R}` ? R : S;

/**
 * Helper function that convert from camelcase every uppercase character.
 * Will need to remove the first unwanter underscode
 *
 * @example CamelToSnakeCase<'FooBarBaz'> // '_foo_bar_baz'
 */
type CamelToSnakeCase<S extends string> = S extends `${infer Head}${infer Rest}`
  ? `${Head extends '_'
      ? ''
      : Head extends Capitalize<Head>
      ? '_'
      : ''}${Lowercase<Head>}${CamelToSnakeCase<Rest>}`
  : S;

/**
 * Converts a string from camelCase to snake_case
 *
 * @example CamelToSnakeCase<'fooBarBaz'> // 'foo_bar_baz'
 */
type SnakeCase<S extends string> = RemoveFirstUnderscore<
  CamelToSnakeCase<S>
>;

type T31 = SnakeCase<"foo_bar">;
//   ^? foo_bar
type T32 = SnakeCase<"FooBar">;
//   ^? foo_bar
type T33 = SnakeCase<"fooBar">;
//   ^? foo_bar

Comments

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;
}

Comments

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[]

Comments

-3

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

5 Comments

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.
Not true: the keyof operator exists.
What I meant by transformation/mapping is transformation of types, not values.
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 This is possible in TypeScript now.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.