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 tosnake_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
andrestString
(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
intoFirst
andRest
using string literal syntax andinfer
keyword. - TypeScript resolves
First
to be a character andRest
to be a string (btw, both are typestring
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 returnStr
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!