The Literal Magic

How I solved typing object key mapping in TypeScript

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 :smile:) 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:

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:

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 :tada:

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 :sweat_smile:. 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

Back to Top