The Literal Magic

How I solved typing object key mapping in TypeScript

Published On: 10 Jul 2022

TL; DR ->

The variable naming conventions differ across tools and programming languages. While the database column names mostly use snake_case, the APIs and data models prefer to use camelCase. Most of the ORMs such as Hibernate and TypeORM do this conversion automatically, making the process seamless. However, I recently came across a use case in TypeScript where this conversion was manual, we had to retain the type information.

The problem: Given an object having the keys in camelCase format, convert them to snake_case retaining Type Information after the conversion.

While it is easy to convert a string to snake_case, retaining the type information is the key here. Let’s start with a simple, recursive (you will know in a short while why we are doing recursion 😄) function to convert a string from camelCase to snake_case.

const isUpperCase = (str: string) => str === str.toUpperCase()

const stringToSnakeCase = (str: string): string => {
  const [first, ...rest] = str
  const restString = rest.join("")
  return str
    ? isUpperCase(first)
      ? `_${first.toLowerCase()}${stringToSnakeCase(
          restString
        )}`
      : `${first}${stringToSnakeCase(restString)}`
    : str
}

The function is pretty simple:

  • First, we destruct the string into first and restString (rest is an array, we join it to get the string)
  • If the first character is an upper case character, we insert _ in the beginning, convert it into lower case and proceed to convert the rest of the string.
  • Otherwise, we keep the first letter as it is, and convert the rest of the string.
  • At the end, the string would be empty, and we return it.

Note: We have to manually add the return type to the function as string, since TS can not infer return types of the recursions yet.

Using this function, we can write a function to convert the keys of an object to snake_case recursively.

type Value = object | string | number | boolean

const _snakify = (obj: Value): Value =>
  typeof obj === "object"
    ? Object.fromEntries(
        Object.entries(obj).map(([key, value]) => [
          stringToSnakeCase(key),
          _snakify(value),
        ])
      )
    : obj

Let’s add a wrapper to this function to take in and return an object.

const snakify = (obj: object): object => _snakify(obj)

To test this function, we declare a constant value and check if the function converts its keys to the snake case correctly.

const obj = {
  hiWorld: 2,
  helloWorld: {
    hiTest: "test",
    hiTestTwo: {
      threeFour: false,
    },
  },
}

const snakified = snakify(obj)

console.log(snakified)

The output is,

{
  hi_world: 2,
  hello_world: { hi_test: "test", hi_test_two: { three_four: false } }
}

This looks fantastic. However, if we want to access any property of the snakified, TypeScript gives us an error, as it had become the plain object.

Adding Types

Now it is the fun part — we want to add types to the snakifyObject function to retain the information about the keys. In the end, we should be able to access snakifiedObject.hi_world, for example, with no errors.

To begin with, let’s define a type to convert a string to snake_case.

type StringToSnakeCase<Str extends string> =
  Str extends `${infer First}${infer Rest}`
    ? First extends Uppercase<First>
      ? `_${Lowercase<First>}${StringToSnakeCase<Rest>}`
      : `${First}${StringToSnakeCase<Rest>}`
    : Str

This type definition looks similar to the definition of our stringToSnakeCase function, pretty much the same thing is happening here but at the type level. Let’s break it down:

  • First, we destruct the string type T into First and Rest using string literal syntax and infer keyword.
  • TypeScript resolves First to be a character and Rest to be a string (btw, both are type string in TypeScript).
  • Then, we do conditional to check if First is uppercase, and add _ to the string based on that.
  • If such destruction can not happen (when Str is empty), we return Str itself.

Now, we can use this type as,

type Test1 = StringToSnakeCase<"helloWorld"> 
// type Test1 = "hello_world"

type Test2 = StringToSnakeCase<"LongListOfWords"> 
// type Test2 == "_long_list_of_words"

type Test3 = StringToSnakeCase<3.21>
// Error: Type 'number' does not satisfy the constraint 'string'

We can proceed to define the type Snakify<T> to convert the keys of the object to snake_case.

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

Here, we check if T is a subtype of object — if it is, we map its string keys using StringToSnakeCase, and recursively Snakify its values; else, we return the type T itself (this will happen at the end of recursion).

Using this type, we can now annotate snakify function as,

const snakifyObject = <T extends object>(obj: T) =>
  _snakify(obj) as Snakify<T>

Now, the return types of the snakified object in the above example are inferred correctly as 🎉

type Result = typeof snakified
/*
type Result = {
    hi_world: number;
    hello_world: {
        hi_test: string;
        hi_test_two: {
            three_four: boolean;
        };
    };
}
*/

Caveats

If there is an _ already present in the key, isUpperCase() in the code and T extends UpperCase<T> test in the type will pass and we get another _. For example,

const obj = {
    hiWorld: 2,
    hi_world: "Hello"
}

const snakified = snakify(obj)

type Result = typeof snakified
/*
type Result = {
    hi_world: number;
    hi__world: string;
}
*/

console.log(snakified)

The output would be,

{ hi_world: 2, hi__world: "Hello" }

While we can fix the code by updating isUpperCase function as,

const isUpperCase = (str: string) =>
  str === str.toUpperCase() &&
  str !== str.toLocaleLowerCase()

to fix the type, first, we need to define a type IsAlpha<T> returning T if T is an alphabet. We can do this as,

type IsAlpha<T extends string> = T extends Uppercase<T> &
  Lowercase<T>
  ? never
  : T

The key point here is that, for a non-alphabet, its uppercase and lowercase would be equal to itself, returning the never type. Now, we can add this chek to StringToSnakeCase type as,

type StringToSnakeCase<T extends string> =
  T extends `${infer First}${infer Rest}`
    ? First extends IsAlpha<First> & Uppercase<First>
      ? `_${Lowercase<First>}${StringToSnakeCase<Rest>}`
      : `${First}${StringToSnakeCase<Rest>}`
    : T

Then, the type Result would be equal to,

type Result = {
    hi_world: string | number;
}

which fixes the issue.

Conclusion and Thoughts

I never thought that TypeScript’s string literal types and pattern matching are useful in real-world scenarios until I came across this use-case 😅. They are undoubtedly powerful type tools in the TypeScript programmer’s arsenal. In a similar spirit to this use case, we can write utilities and associated types to convert strings across various conventions.

One thing I really thought about was code-duplication. Across this example, we wrote similar logic both in code and type, achieving the same things. Since the type information does not exist at all during runtime, unfortunately, this seems to be the only way. I’m really curious about how problems similar to this, that involve type manipulation and mapping are handled in other programming languages; please let me know if you come across such situations before!

TL;DR

  • I came across a problem recently where I had to type the conversion of object keys from camelCase to snake_case in TypeScript.

  • While implementing the utilities is an easy task, making them contain type information was crucial.

  • Using TypeScript’s template literal string and pattern matching, first, I defined a type to convert strings from camelCase to snake_case.

  • By mapping through keys of the object, and converting them to snake_case using the utility defined earlier, I was able to convert the keys of the object type.

  • Adding type annotations required a similar kind of computation, except that it was on types. I wondered if we can reduce the double computation, anyway it is not possible since the type computations exist only during compile time 😅