'Typescript match first uppercased letter on type level
I want to transform a string literal from camelCase to snake_case, like:
type CamelCaseStr = "helloWorldAgain"
type _ = ToSnakeCase<CamelCaseStr> // "hello_world_again"
Here is my Solution:
type str = "helloWorldAgain";
type UpperCollection = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
type Chars<T extends string> = T extends `${infer Char}${infer Rest}` ? Char | Chars<Rest> : never;
type UpperLetters = Chars<UpperCollection>;
type ToSnakeCase<T extends string> =
T extends `${infer LowerStr}${UpperLetters}${infer AfterFirstBigLetter}` ?
T extends `${LowerStr}${infer WithFirstBigLetter}` ?
`${LowerStr}_${ToSnakeCase<Uncapitalize<WithFirstBigLetter>>}` :
LowerStr :
T;
type _ = ToSnakeCase<str>;
// type _ = "helloWorld_again" | "helloWorld_world_again" | "hello_again" | "hello_world_again"
I attempted to extract a LowerStr
until firstly encountered an uppercased letter, therefore extract first capitalized str, then Uncapitalize
it and use ToSnakeCase
again. However, the result is wrong, so I wrote some test code.
type TestToSnakeCase<T extends string> =
T extends `${infer LowerStr}${UpperLetters}${infer AfterFirstBigLetter}` ?
T extends `${LowerStr}${infer WithFirstBigLetter}` ?
[LowerStr, WithFirstBigLetter] :
never
: T;
type __ = TestToSnakeCase<str>;
// type __ = ["helloWorld" | "hello", "Again" | "WorldAgain"];
The problem seems to be reduced down to shut discrimination. I searched and found this link useful, with which I tried again.
type ShutDiscrimination<T extends string> =
[T] extends [`${infer LowerStr}${UpperLetters}${infer AfterFirstBigLetter}`] ?
[T] extends [`${LowerStr}${infer WithFirstBigLetter}`] ?
`${LowerStr}_${ShutDiscrimination<Uncapitalize<WithFirstBigLetter>>}` :
LowerStr :
T;
type ___ = ShutDiscrimination<str>;
// type ___ = "helloWorld_again" | "helloWorld_worldAgain" | "hello_again" | "hello_worldAgain"
Is there anything critical I missed to solve this problem? I hope I expressed my idea well, formatting is quite cumbersome:)
Solution 1:[1]
The main issue with your code is that you are using UpperLetters
directly in the conditional clause where you infer. Because UpperLetters
can only be a single character, you should instead infer the character then check if it extends
UpperLetters
. This will flatten the unions you see.
type UpperLetter = "a" | "b";
type Parse<S extends string> = S extends
`${infer $Start}${infer $UpperLetter}${infer $Rest}`
? $UpperLetter extends UpperLetter ? true : false
: false;
Anyways, I attempted the problem myself without using a static UpperLetter
union by checking if Capitalize<S>
is equal to S
. Here it is:
type CamelToSnakeCase<S extends string, $Acc extends string = ""> =
//
S extends `${infer $Ch}${infer $Rest}`
? S extends Capitalize<S>
? CamelToSnakeCase<$Rest, `${$Acc}_${Lowercase<$Ch>}`>
: CamelToSnakeCase<$Rest, `${$Acc}${$Ch}`>
: $Acc;
type foo = CamelToSnakeCase<"helloAwesomeWorld">; // "hello_awesome_world"
type foo2 = CamelToSnakeCase<"">; // ""
type foo3 = CamelToSnakeCase<"bruhSOCool">; // "bruh_s_o_cool"
type SnakeToCamelCase<S extends string, $Acc extends string = ""> =
//
S extends `${infer $Ch}${infer $Rest}`
? $Ch extends "_"
? $Rest extends `${infer $Ch}${infer $Rest}`
? SnakeToCamelCase<$Rest, `${$Acc}${Capitalize<$Ch>}`>
: ""
: SnakeToCamelCase<$Rest, `${$Acc}${$Ch}`>
: $Acc;
type asdf = SnakeToCamelCase<"hello_awesome_world">; // "helloAwesomeWorld"
type asdf2 = SnakeToCamelCase<"bruh_s_o_cool">; // "bruhSOCool"
type asdf3 = SnakeToCamelCase<"">; // ""
type asdf4 = SnakeToCamelCase<"so_cool_bro">; // "soCoolBro"
Edit: It was not the union that fixed your code. It was the fact that I inferred the next letter when I did ${infer $Ch}${infer $Rest}
then checked if it was uppercase via Capitalize
. You were doing ${infer LowerStr}${UpperLetters}${infer AfterFirstBigLetter}
which computes the value of the string for each branch in UpperLetters
which results in having issues with multiple types for LowerStr
and AfterFirstBigLetter
. Here's an example:
type UpperLetter = "A" | "B" | "C" | "D" // ..
// This is going to go through each computed type in the extends clause and come up with multiple
// answers in the union for after the various characters.
type bad = "ABCD" extends `${string}${UpperLetter}${infer $After}` ? $After : never; // "" | "D" | "BCD" | "CD"
// Now, let's fix the code by writing an infer character to force the extends clause to only have one possible combination.
// This can be done by replacing `UpperLetter` with `infer $Ch` because any `string` / `infer $Ch` and removing the leading
// `${string}` part.
type fixed = "ABCD" extends `${infer $Ch}${infer $After}` ? $Ch extends UpperLetter ? $After : never : never; // "BCD"
Solution 2:[2]
Here is my solution, inspired by @sno2
I've focused there is only just one way comparing 'target character' with recursions for no Alphabet Collection
.
export type CamelCaseToSnakeCase<
T extends string,
Joiner extends '' | '_' = ''
> = T extends `${infer Character}${infer Rest}`
? Character extends Uppercase<Character>
? `${Joiner}${Lowercase<Character>}${CamelCaseToSnakeCase<Rest, '_'>}`
: `${Character}${CamelCaseToSnakeCase<Rest, '_'>}`
: '';
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
Solution | Source |
---|---|
Solution 1 | |
Solution 2 | cyan-kinesin |