beraliv

Transform string literal type into camelCase in TypeScript

Example of CamelCase use
1type CamelCase<T> = any; // implementation
2
3type 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:

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

All possible cases
1type cases = [
2 // empty string
3 Expect<Equal<CamelCase<"">, "">>,
4 // oneword
5 Expect<Equal<CamelCase<"oneword">, "oneword">>,
6 Expect<Equal<CamelCase<"ONEWORD">, "oneword">>,
7 // snake_case
8 Expect<Equal<CamelCase<"two_words">, "twoWords">>,
9 Expect<Equal<CamelCase<"here_three_words">, "hereThreeWords">>,
10 // CONSTANT_CASE
11 Expect<Equal<CamelCase<"TWO_WORDS">, "twoWords">>,
12 Expect<Equal<CamelCase<"HERE_THREE_WORDS">, "hereThreeWords">>,
13 // kebab-case
14 Expect<Equal<CamelCase<"two-words">, "twoWords">>,
15 Expect<Equal<CamelCase<"here-three-words">, "hereThreeWords">>,
16 // COBOL-CASE
17 Expect<Equal<CamelCase<"TWO-WORDS">, "twoWords">>,
18 Expect<Equal<CamelCase<"HERE-THREE-WORDS">, "hereThreeWords">>,
19 // camelCase
20 Expect<Equal<CamelCase<"twoWords">, "twoWords">>,
21 Expect<Equal<CamelCase<"hereThreeWords">, "hereThreeWords">>,
22 // PascalCase
23 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:

Separator in different positions
1type cases = [
2 // separator at the start and the end
3 Expect<Equal<CamelCase<"two-words-">, "twoWords">>,
4 Expect<Equal<CamelCase<"-two-words">, "twoWords">>,
5 Expect<Equal<CamelCase<"-two-words-">, "twoWords">>,
6 // repeated separator
7 Expect<Equal<CamelCase<"two----words">, "twoWords">>,
8 Expect<Equal<CamelCase<"----two----words">, "twoWords">>,
9 Expect<Equal<CamelCase<"-----two----words----">, "twoWords">>,
10 // mixed separators
11 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:

Type 'string' and other types
1type cases = [
2 // string
3 Expect<Equal<CamelCase<string>, string>>,
4 // other types
5 // @ts-expect-error: Expected `string` but got `number` instead
6 CamelCase<number>,
7 // @ts-expect-error: Expected `string` but got `symbol` instead
8 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

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:

Extracting words
1type Separator = "_" | "-";
2
3type 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:

Filtering empty words
1type Separator = "_" | "-";
2
3type FilterEmptyWord<Word, T extends unknown[]> = Word extends ""
4 ? T
5 : [Word, ...T];
6
7type 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:

Current result for mixed separators
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:

Reproduce the problem with mixed separators
1type Separator = "_" | "-";
2
3type Test<S> = S extends `${infer Word}${Separator}${infer Rest}`
4 ? [Word, Rest]
5 : [];
6
7type 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.:

Split into several conditional types
1type FilterEmptyWord<Word, T extends unknown[]> = Word extends ""
2 ? T
3 : [Word, ...T];
4
5type 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:

  1. Split a string by hyphen

GIVEN a string WHEN it has underscore _ AND previous character is - THEN split a string by hyphen -

  1. Split a string by underscore

GIVEN a string has underscore _ WHEN previous character is NOT a hyphen THEN split a string by underscore _

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

Validate mixed separators
1type Separator = "_" | "-";
2
3type FilterEmptyWord<Word, T extends unknown[]> = Word extends ""
4 ? T
5 : [Word, ...T];
6
7type SplitBySeparator<S> = S extends `${infer Word}${Separator}${infer Rest}`
8 ? FilterEmptyWord<Word, SplitBySeparator<Rest>>
9 : FilterEmptyWord<S, []>;
10
11type IsRepeatedSeparator<Ch, Validated> = Ch extends Separator
12 ? Validated extends `${string}${Separator}`
13 ? true
14 : false
15 : false;
16
17type RemoveRepeatedSeparator<
18 NotValidated,
19 Validated = ""
20> = NotValidated extends `${infer Ch}${infer Rest}`
21 ? IsRepeatedSeparator<Ch, Validated> extends true
22 ? RemoveRepeatedSeparator<Rest, Validated>
23 : RemoveRepeatedSeparator<Rest, `${Validated & string}${Ch}`>
24 : Validated;
25
26type 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:

  1. Previous solution Words was renamed to SplitBySeparator
  2. RemoveRepeatedSeparator is used to validate a string and to remove repeated separators
  3. IsRepeatedSeparator 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:

Split solution into 2 different approaches
1type WhichApproach<S> = S extends `${string}${Separator}${string}`
2 ? "separatorBased"
3 : "capitalBased";
4
5type 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.

https://tsplay.dev/N71DnW 🏝

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:

Extracting words for camel and pascal cases
1type IsUppercase<Ch extends string> = [Ch] extends [Uppercase<Ch>]
2 ? true
3 : false;
4
5type 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 true
13 ? 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.

https://tsplay.dev/w6PD0m 🏝

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?

  1. When we have pascal case, IsUppercase<Ch> extends true is correct, so we add empty string to Words the next line.
  2. 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:

Update filtering empty words
1type FilterEmptyWord<
2 Word,
3 T extends unknown[],
4 S extends "start" | "end"
5> = Word extends ""
6 ? T
7 : {
8 start: [Word, ...T];
9 end: [...T, Word];
10 }[S];

Together it will look as the following:

Removing empty string from words
1type FilterEmptyWord<
2 Word,
3 T extends unknown[],
4 S extends "start" | "end"
5> = Word extends ""
6 ? T
7 : {
8 start: [Word, ...T];
9 end: [...T, Word];
10 }[S];
11
12type IsUppercase<Ch extends string> = [Ch] extends [Uppercase<Ch>]
13 ? true
14 : false;
15
16type 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 true
24 ? 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:

Add support for camel and upper cases
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:

  1. Make first word lower case
  2. Capitalise other words

We start from WordCase to support both lower and pascal case:

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

Make all words pascal case
1type PascalCasify<T, R extends unknown[] = []> = T extends [
2 infer Head,
3 ...infer Rest
4]
5 ? PascalCasify<Rest, [...R, WordCase<Head, "pascal">]>
6 : R;

And will use it in CamelCasify:

Make first word lower case and other pascal case
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:

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:

Final solution
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

Comments

Alexey Berezin profile image

Written by Alexey Berezin who loves London 🏴󠁧󠁢󠁥󠁮󠁧󠁿, players ⏯ and TypeScript 🦺 Follow me on Twitter