Breaking Down TypeScript SnakeToCamelCase Generic
Some TypeScript can appear extremely complex. This snippet I discovered is particularly unwelcoming. Let's break it down into bite sized pieces to understand what's going on.
This is a TypeScript generator that takes a string type that is in snake case and transforms it into camel case.
This is useful if an API supports both snake case and camel case, the generated types use the snake case version, but you want to use camel case.
type SnakeToCamelCase<S extends string> =
S extends `${infer T}_${infer U}` ?
`${Lowercase<T>}${Capitalize<SnakeToCamelCase<U>>}` :
Lowercase<S>
const camelCaseString: SnakeToCamelCase<'FROG_AND_TOAD'> = 'frogAndToad';Source: https://dev.to/svehla/typescript-transform-case-strings-450b#comment-1dkam
To understand how it works we can break it down into the key TypeScript features - the generic type alias, infer, ternary and casing conversion.
Generic Type Alias
A Generic Type Alias (or "generic" for short) lets us create reusable type definitions.
Consider the type definition of a function that calls an API.
const callApi = ({ url }) => {
return fetch(url)
.then((response) => {
return response.json()
})
}When we use the function the response object won't have a type defined. We could define a type. But it will be incorrect if we used a different API endpoint.
interface ApiResponse {
id: number;
name: string;
email: string;
status: string;
}
const callApi = ({ url }: { url: string }): Promise<ApiResponse> => {
return fetch(url)
.then((response) => {
return response.json()
})
}The solution is to implement a generic in the callApi function. The response type is attached to the function in <> brackets before the ( - callApi<ApiResponse>.
The callApi function can then use that type value by appending <T> before the ( of the function. T is similar to a variable. It can be used anywhere inside the function. Here it is used to define the response type - Promise<T>.
interface ApiResponse {
id: number;
name: string;
email: string;
status: string;
}
const callApi = <T>({ url }: { url: string }): Promise<T> => {
return fetch(url)
.then((response) => {
return response.json()
})
}
callApi<ApiResponse>({ url: 'https://api.example.com/user/1' })Generics can also be used when dealing with strings as well as functions. Here we have a generic that makes a type definition nullable - string or null.
type Nullable<T> = T | null;
const someVariable: Nullable<string> = null;This generic makes each property of an object nullable.
type Nullable<T> = { [K in keyof T]: T[K] | null };
interface ApiResponse {
id: number;
name: string;
email: string;
status: string;
}
const nullableApiResponse: Nullable<ApiResponse> = someDataThe alternative would be to manually write it out.
interface ApiResponse {
id: number | null;
name: string | null;
email: string | null;
status: string | null;
}Ternary
TypeScript ternaries work with generics to return alternate types.
In this example the thisOrThat variable has a type definition of either this or that. If we use ThisOrThat<true> the type would become this. If we use ThisOrThat<false> the type would become that.
type ThisOrThat<T> = T extends true ? 'this' : 'that';
const thisOrThat: ThisOrThat<false> = 'that';Infer
TypeScript can infer types without them being declared manually. TypeScript is capable of determining that x is a number given const x = 3. But in this example we look at the infer keyword. The infer keyword is only used in conditional types and is different to general TypeScript inference.
GetReturnType returns the type of a function. In this case a string.
First we have a ternary condition.
IF T extends () => infer ReturnValue is satisfied.
THEN return ReturnValue
ELSE return null
But what does T extends () => infer ReturnValue mean?
T extends X checks if the T type can be assigned to X.
In T extends () => infer ReturnValue it checks if T can be assigned to a function that returns "something".
If the ternary is satisfied then infer ReturnValue captures the return type of T (MyFunction).
ReturnType is then returned.
type GetReturnType<T> = T extends () => infer ReturnValue ? ReturnValue : null;
type MyFunction = () => string
type StringReturn = GetReturnType<MyFunction>;Casing
TypeScript offers Uppercase, Lowercase and Capitalize to convert the casing of type definitions.
type UppercaseType = Uppercase<'Hello world'> // HELLO WORLD
type LowercaseType = Lowercase<'Hello World'> // hello world
type CapitalizeType = Capitalize<'hello world'>; // 'Hello world'Putting them together
Consider that we want to convert FROG_AND_TOAD to frogAndToad.
const camelCaseString: SnakeToCamelCase<'FROG_AND_TOAD'> = 'frogAndToad';The flow of events would look like this.
1. FROG_AND_TOAD loaded into SnakeToCamelCase generic
2. S extends string makes sure FROG_AND_TOAD is a string
3. S extends '${infer T}_${infer U}' checks if S matches string_string
4. If it does then it returns a new string that is the left side of the underscore and the right side of the underscore, but with the left side lowercase and the first character of the right side capitalised
5. Load the right side into SnakeToCamelCase and repeat
6. If extends '${infer T}_${infer U}' is false return the final string, which is lowercased
But this is a recursive generic, so what actually happens is this.
Recursion 1
1. SnakeToCamelCase<FROG_AND_TOAD>
2. Lowercase<FROG> + Capitalise(result of processing 'AND_TOAD')
2. frog + Capitalise(result of processing 'AND_TOAD')
Recursion 2
1. SnakeToCamelCase<AND_TOAD>
2. Lowercase<AND> + Capitalise(result of processing 'TOAD')
2. and + Capitalise(result of processing 'TOAD')
Recursion 3
1. SnakeToCamelCase<TOAD>
2. Lowercase<TOAD> = toad
Unwinding Recursion 2
and + Capitalise(toad) = and + Toad = andToad
Unwinding Recursion 1
frog + Capitalize(andToad) = frog + AndToad = frogAndToad
Result
frogAndToad

