Transform string literal type into camelCase in TypeScript
1type CamelCase<T> = any; // implementation23type cases = [4 Expect<Equal<CamelCase<"ONEWORD">, "oneword">>,5 Expect<Equal<CamelCase<"two_words">, "twoWords">>,6 Expect<Equal<CamelCase<"TwoWords">, "twoWords">>,7 Expect<Equal<CamelCase<"HERE-THREE-WORDS">, "hereThreeWords">>8];
Today we discuss CamelCase.
Let's have a look ⤵️
Scope of solution
Previously there was another medium challenge which was removed. The quick difference between them in a table:
CamelCase | medium | hard |
---|---|---|
empty string | ✅ | ✅ |
oneword | ❌ | ✅ |
snake_case | ❌ | ✅ |
CONSTANT_CASE | ❌ | ✅ |
kebab-case | ✅ | ❌ |
COBOL-CASE | ❌ | ❌ |
camelCase | ❌ | ❌ |
PascalCase | ❌ | ❌ |
Playgrounds for 2 solutions – https://tsplay.dev/WvV9AW & https://tsplay.dev/Wyb06w.
Let's solve this type challenge differently: to define wider test cases and come up with the solution for them.
Test cases
Let's include all cases we mentioned in a table:
1type cases = [2 // empty string3 Expect<Equal<CamelCase<"">, "">>,4 // oneword5 Expect<Equal<CamelCase<"oneword">, "oneword">>,6 Expect<Equal<CamelCase<"ONEWORD">, "oneword">>,7 // snake_case8 Expect<Equal<CamelCase<"two_words">, "twoWords">>,9 Expect<Equal<CamelCase<"here_three_words">, "hereThreeWords">>,10 // CONSTANT_CASE11 Expect<Equal<CamelCase<"TWO_WORDS">, "twoWords">>,12 Expect<Equal<CamelCase<"HERE_THREE_WORDS">, "hereThreeWords">>,13 // kebab-case14 Expect<Equal<CamelCase<"two-words">, "twoWords">>,15 Expect<Equal<CamelCase<"here-three-words">, "hereThreeWords">>,16 // COBOL-CASE17 Expect<Equal<CamelCase<"TWO-WORDS">, "twoWords">>,18 Expect<Equal<CamelCase<"HERE-THREE-WORDS">, "hereThreeWords">>,19 // camelCase20 Expect<Equal<CamelCase<"twoWords">, "twoWords">>,21 Expect<Equal<CamelCase<"hereThreeWords">, "hereThreeWords">>,22 // PascalCase23 Expect<Equal<CamelCase<"TwoWords">, "twoWords">>,24 Expect<Equal<CamelCase<"HereThreeWords">, "hereThreeWords">>25];
Now, let's think what else we can add here.
If we check CamelCase (medium) test cases once again https://tsplay.dev/WvV9AW, there are a lot of tests where a separator -
is in all possible positions, let's add them too:
1type cases = [2 // separator at the start and the end3 Expect<Equal<CamelCase<"two-words-">, "twoWords">>,4 Expect<Equal<CamelCase<"-two-words">, "twoWords">>,5 Expect<Equal<CamelCase<"-two-words-">, "twoWords">>,6 // repeated separator7 Expect<Equal<CamelCase<"two----words">, "twoWords">>,8 Expect<Equal<CamelCase<"----two----words">, "twoWords">>,9 Expect<Equal<CamelCase<"-----two----words----">, "twoWords">>,10 // mixed separators11 Expect<Equal<CamelCase<"two-_--__words">, "twoWords">>,12 Expect<Equal<CamelCase<"two-_--__words-_--__">, "twoWords">>,13 Expect<Equal<CamelCase<"-_--__two-_--__words">, "twoWords">>,14 Expect<Equal<CamelCase<"-_--__two-_--__words-_--__">, "twoWords">>15];
Also last but not least, in case of string
we should return string
as well. Other types are not allowed:
1type cases = [2 // string3 Expect<Equal<CamelCase<string>, string>>,4 // other types5 // @ts-expect-error: Expected `string` but got `number` instead6 CamelCase<number>,7 // @ts-expect-error: Expected `string` but got `symbol` instead8 CamelCase<symbol>9];
I combined all of them together in Playground – https://tsplay.dev/NabBEm
Looks neat 👀 Let's solve it now ⬇️
Steps
Now we need to think how we want to solve this challenge. Let's break it into steps using a Gherkin syntax:
- Split into words – GIVEN a string, THEN it is split into words
- Make it camel case – GIVEN words, THEN first word becomes lowercase AND other words become Uppercase
- Formatted words are joined together – GIVEN formatted words, THEN they are joined together
- GIVEN different cases, THEN result is in camel case
Split into words
Words
To be able to understand how to format a string, we need to extract words from it. Let's define a type Words
. Given a string, it returns a tuple with words:
1type Separator = "_" | "-";23type Words<S> = S extends `${infer Word}${Separator}${infer Rest}`4 ? [Word, ...Words<Rest>]5 : [];
Checking solution in Playground – https://tsplay.dev/N7P9Dm, we still see that we have problems with separators in different places.
Separators in different places
For this particular one, we need to filter words which are empty:
1type Separator = "_" | "-";23type FilterEmptyWord<Word, T extends unknown[]> = Word extends ""4 ? T5 : [Word, ...T];67type Words<S> = S extends `${infer Word}${Separator}${infer Rest}`8 ? FilterEmptyWord<Word, Words<Rest>>9 : FilterEmptyWord<S, []>;
We added FilterEmptyWord
which checks if a string is empty. Based on it, it returns a tuple with a word or not. We used it in Words
.
A link to a Playground – https://tsplay.dev/NrKklm
Mixed separators
I know it's a rare case, but let's try to understand why it's broken.
If you want to dive into details, please continue reading. Otherwise, I would suggest to skip it as it's not a common case and only helpful in educational purposes.
Having a look at any of them, we see that unions are involved in these examples:
1type MixedSeparator = Words<"two-_--__words">;2// ^? ["two-", "words"] | ["two-", "-", "words"] | ["two-", "--", "words"] | ["two-", "--", "-", "words"] | ["two-", "_", "words"] | ["two-", "_", "-", "words"] | ["two-", "_", "--", "words"] | ... 8 more ... | [...]
So we see the problem because of the conditional type S extends `${infer Word}${Separator}${infer Rest}`
which distributes string into several union elements because we have alternatives how to split a string for different separators.
Let me demonstrate it here:
1type Separator = "_" | "-";23type Test<S> = S extends `${infer Word}${Separator}${infer Rest}`4 ? [Word, Rest]5 : [];67type Step1 = Test<"a-_b">;8// ^? ["a-" | "a", "b" | "_b"]
Same example in Playground – https://tsplay.dev/m0okON 🐞
One possible way to solve it is to split it into several conditional types, e.g.:
1type FilterEmptyWord<Word, T extends unknown[]> = Word extends ""2 ? T3 : [Word, ...T];45type Words<S> = S extends `${infer Word}_${infer Rest}`6 ? Word extends `${string}-`7 ? S extends `${infer Word}-${infer Rest}`8 ? FilterEmptyWord<Word, Words<Rest>>9 : FilterEmptyWord<S, []>10 : FilterEmptyWord<Word, Words<Rest>>11 : S extends `${infer Word}-${infer Rest}`12 ? FilterEmptyWord<Word, Words<Rest>>13 : FilterEmptyWord<S, []>;
It looks complicated – https://tsplay.dev/N9yooN, but what we do here:
- Split a string by hyphen
GIVEN a string
WHEN it has underscore _
AND previous character is -
THEN split a string by hyphen -
- Split a string by underscore
GIVEN a string has underscore _
WHEN previous character is NOT a hyphen
THEN split a string by underscore _
- String is oneword
GIVEN a string
WHEN it has neither underscore _
nor hyphen -
THEN it is NOT split
I don't like this approach because if we have another separator, we need to adapt the algorithm here, which takes time
Another way is to prepare a string before using Words
:
1type Separator = "_" | "-";23type FilterEmptyWord<Word, T extends unknown[]> = Word extends ""4 ? T5 : [Word, ...T];67type SplitBySeparator<S> = S extends `${infer Word}${Separator}${infer Rest}`8 ? FilterEmptyWord<Word, SplitBySeparator<Rest>>9 : FilterEmptyWord<S, []>;1011type IsRepeatedSeparator<Ch, Validated> = Ch extends Separator12 ? Validated extends `${string}${Separator}`13 ? true14 : false15 : false;1617type RemoveRepeatedSeparator<18 NotValidated,19 Validated = ""20> = NotValidated extends `${infer Ch}${infer Rest}`21 ? IsRepeatedSeparator<Ch, Validated> extends true22 ? RemoveRepeatedSeparator<Rest, Validated>23 : RemoveRepeatedSeparator<Rest, `${Validated & string}${Ch}`>24 : Validated;2526type Words<S> = SplitBySeparator<RemoveRepeatedSeparator<S>>;
Previous solution was in 13 lines, and this one is twice as much, but we got rid of the potential repetition which is good:
- Previous solution
Words
was renamed toSplitBySeparator
RemoveRepeatedSeparator
is used to validate a string and to remove repeated separatorsIsRepeatedSeparator
checks whether separator is repeated or not
Playground – https://tsplay.dev/mZ4RPw
If we have another separator in the future, we only need to update
Separator
, which is useful
Let's stop here and move on to camel and Pascal cases.
Pascal and camel cases
At this point we cannot identify camel and pascal cases, because there is no separator between words. We can only spot it by capital letters in a word.
To be able to distinguish whether we have a separator or not, I came up with a type WhichApproach
:
1type WhichApproach<S> = S extends `${string}${Separator}${string}`2 ? "separatorBased"3 : "capitalBased";45type Words<S> = {6 separatorBased: SplitBySeparator<RemoveRepeatedSeparator<S>>;7 capitalBased: [S];8}[WhichApproach<S>];
We use lazy evaluation here to get SplitBySeparator<RemoveRepeatedSeparator<S>>
or [S]
based on what WhichApproach<S>
is.
As oneword strings are now capital letter based, I used capitalBased: [S]
to not break it.
As a next step, let's create SplitByUppercase
and IsUppercase
for capital based approach:
1type IsUppercase<Ch extends string> = [Ch] extends [Uppercase<Ch>]2 ? true3 : false;45type SplitByCapital<6 S,7 Word extends string = "",8 Words extends unknown[] = []9> = S extends ""10 ? [...Words, Word]11 : S extends `${infer Ch}${infer Rest}`12 ? IsUppercase<Ch> extends true13 ? SplitByCapital<Rest, Ch, [...Words, Word]>14 : SplitByCapital<Rest, `${Word}${Ch}`, Words>15 : [];
Long story short, we iterate over letters and accumulate each new word in Word
until we reach capital letter. We understand it when IsUppercase
returns true. When a string is empty, a tuple with all words is returned.
That's not final solution. If you have a look at it, you will see that it's correctly working for lowercase oneword and camel case. How come?
- When we have pascal case,
IsUppercase<Ch> extends true
is correct, so we add empty string toWords
the next line. - When we have uppercase oneword, we split letters one by one, because
IsUppercase<Ch> extends true
treats letters as different words.
To be able to fix the first problem, we already have a type FilterEmptyWord
but we need to add Word
at the end, not the beginning. I updated it as:
1type FilterEmptyWord<2 Word,3 T extends unknown[],4 S extends "start" | "end"5> = Word extends ""6 ? T7 : {8 start: [Word, ...T];9 end: [...T, Word];10 }[S];
Together it will look as the following:
1type FilterEmptyWord<2 Word,3 T extends unknown[],4 S extends "start" | "end"5> = Word extends ""6 ? T7 : {8 start: [Word, ...T];9 end: [...T, Word];10 }[S];1112type IsUppercase<Ch extends string> = [Ch] extends [Uppercase<Ch>]13 ? true14 : false;1516type SplitByCapital<17 S,18 Word extends string = "",19 Words extends unknown[] = []20> = S extends ""21 ? FilterEmptyWord<Word, Words, "end">22 : S extends `${infer Ch}${infer Rest}`23 ? IsUppercase<Ch> extends true24 ? SplitByCapital<Rest, Ch, FilterEmptyWord<Word, Words, "end">>25 : SplitByCapital<Rest, `${Word}${Ch}`, Words>26 : [];
Much better – https://tsplay.dev/WPRQEN 🏝
Fixing uppercase oneword is not a big deal. We can just add one conditional type IsUppercase
and if it is true
, then return the string as is. Together with SplitByCapital
it will look like:
1type Words<S> = {2 separatorBased: SplitBySeparator<RemoveRepeatedSeparator<S>>;3 capitalBased: IsUppercase<S & string> extends true ? [S] : SplitByCapital<S>;4}[WhichApproach<S>];
Voila 💫 Well done – https://tsplay.dev/w8EDPN 🏝
Make it camel case
Now we have words and we need to prepare them before joining together. To do so, we need:
- Make first word lower case
- Capitalise other words
We start from WordCase
to support both lower and pascal case:
1type WordCase<S, C extends "pascal" | "lower"> = {2 pascal: Capitalize<WordCase<S, "lower"> & string>;3 lower: Lowercase<S & string>;4}[C];
Iterating over words, let's apply pascal case:
1type PascalCasify<T, R extends unknown[] = []> = T extends [2 infer Head,3 ...infer Rest4]5 ? PascalCasify<Rest, [...R, WordCase<Head, "pascal">]>6 : R;
And will use it in CamelCasify
:
1type CamelCasify<T> = T extends [infer Head, ...infer Rest]2 ? PascalCasify<Rest, [WordCase<Head, "lower">]>3 : [];
Almost done here, now we have type CamelCaseWords<S> = CamelCasify<Words<S>>;
.
I updated tests to be able to test transformations with words – https://tsplay.dev/NdAedm 🏝
Well done ✅
Formatted words are joined together
Given a tuple with all words in correct case, we need to join them together. Let's write Join
:
1type Join<T, S extends string = ""> = T extends [infer Word, ...infer Rest]2 ? Join<Rest, `${S}${Word & string}`>3 : S;
As you see here, we get the word from tuple and add it to the end of string literal. Therefore, at the end we will get the concatenated string.
I used Word & string
to get rid of extra conditional type where I would check that it's the string (we know it's a string here anyway).
And after all, combining 3 types Join
, CamelCasify
and Words
, we can create CamelCase
:
1type CamelCase<S extends string> = Join<CamelCasify<Words<S>>>;
Don't forget about generic constrain as we also have tests for types other than a string.
Playground – https://tsplay.dev/N71rPW 👏
Thank you for your attention and have a wonderful evening and the rest of the weekend!
Cheers 👋
typescript