Split string literal type in TypeScript
1type Split<S extends string, Separator extends string> = any; // implementation23type cases = [4 Expect<Equal<Split<"Two words", " ">, ["Two", "words"]>>,5 Expect<Equal<Split<"One-word", "">, ["O", "n", "e", "-", "w", "o", "r", "d"]>>6];
Today we discuss Split
The type Split
is identical to the method Array.prototype.split
in JavaScript
Let's step by step implement it 🔥
Substrings
To be able to extract words from the string, we will use recursive conditional type.
1type Split<2 S extends string,3 Separator extends string4> = S extends `${infer Word}${Separator}${infer Rest}`5 ? [Word, ...Split<Rest, Separator>]6 : [];
Here we use S extends `${infer Word}${Separator}${infer Rest}`
to match the pattern `${infer Word}${Separator}${infer Rest}`
.
It means that if we have a separator, it will infer left and right parts.
On the left part Word
we have a word so we will put it into the tuple.
For the right part Rest
we have the rest of a string, so let's recursively do until we have an empty string.
Let's save the result in Playground – https://tsplay.dev/N52MBm and check if it's working correctly.
1type Split<2 S extends string,3 Separator extends string4> = S extends `${infer Word}${Separator}${infer Rest}`5 ? [Word, ...Split<Rest, Separator>]6 : [];78type Test1 = Split<"Hi! How are you?", "z">; // []9type Test2 = Split<"Hi! How are you?", " ">; // ["Hi!", "How", "are"]
First 2 cases return incorrect results.
We expect ["Hi! How are you?"]
for Test1
and ["Hi!", "How", "are", "you?"]
for Test2
but didn't get the last word for both of them.
That happens because on the last iteration where we cannot match the pattern `${infer Word}${Separator}${infer Rest}`
so it returns nothing instead of the last word.
Let's fix it 🧪
Separator is not a part of the string
To be able to fix the last iteration, let's return the rest of the string.
1type Split<2 S extends string,3 Separator extends string4> = S extends `${infer Word}${Separator}${infer Rest}`5 ? [Word, ...Split<Rest, Separator>]6 : [S];
and check the Playground as well – https://tsplay.dev/NDGM4w
We see that previous cases are fixed now
Let's have a look at the next broken.
1type Split<2 S extends string,3 Separator extends string4> = S extends `${infer Word}${Separator}${infer Rest}`5 ? [Word, ...Split<Rest, Separator>]6 : [S];78// ["H", "i", "!", " ", "H", "o", "w", " ", "a", "r", "e", " ", "y", "o", "u", "?", ""]9type Test1 = Split<"Hi! How are you?", "">;
We see that we got empty string at the end of the tuple.
For empty separator pattern matching `${infer Word}${Separator}${infer Rest}`
always goes to "then" branch except for the case with the empty string S
at the last iteration
1// [""]2type LastIteration = "" extends `${infer Word}${""}${infer Rest}`3 ? [Word, Rest]4 : [""];
Incorrect empty string in the result
So we faced the issue with the empty string at the last iteration.
Let's check if we have an empty string S
, and if we have it, we don't add it to the result:
1type Split<2 S extends string,3 Separator extends string4> = S extends `${infer Word}${Separator}${infer Rest}`5 ? [Word, ...Split<Rest, Separator>]6 : S extends ""7 ? []8 : [S];
We only added an auxiliary conditional type S extends ""
If we check the Playground with this solution – https://tsplay.dev/m0LMqm, we see this is fixed now.
Let's check another broken case with the empty string.
1type Split<2 S extends string,3 Separator extends string4> = S extends `${infer Word}${Separator}${infer Rest}`5 ? [Word, ...Split<Rest, Separator>]6 : S extends ""7 ? []8 : [S];910type Test1 = Split<"", "z">; // []
By the spec of Array.prototype.split
, it should return an array with an empty string if the separator is not empty
1"".split("z"); // [""]
Let's handle this edge case as well.
1type Split<2 S extends string,3 Separator extends string4> = S extends `${infer Word}${Separator}${infer Rest}`5 ? [Word, ...Split<Rest, Separator>]6 : S extends ""7 ? Separator extends ""8 ? []9 : [S]10 : [S];
Good, now it's working as expected – https://tsplay.dev/wEDMyW 🎉
Difference between string and string literal type
If we have a look at the previous solution, we see that we still have one broken test. Let's take a closer look.
1type Split<2 S extends string,3 Separator extends string4> = S extends `${infer Word}${Separator}${infer Rest}`5 ? [Word, ...Split<Rest, Separator>]6 : S extends ""7 ? Separator extends ""8 ? []9 : [S]10 : [S];1112type Test1 = Split<string, "whatever">; // [string]
If we split string
, we want to see string[]
as the result, right? We need to understand how to distinguish strings and string literal types.
Let's come up with the solution for it:
1type IsStringLiteralType<T> = any; // implementation23type cases = [4 Expect<Equal<IsStringLiteralType<string>, false>>,5 Expect<Equal<IsStringLiteralType<number>, false>>,6 Expect<Equal<IsStringLiteralType<"123">, true>>,7 Expect<Equal<IsStringLiteralType<123>, false>>8];
First of all, we understand that we have a string with conditional type T extends string
so let's add it first.
1type IsStringLiteralType<T> = T extends string ? true : false;
To be able to check the solution, let's have a look at the one broken case – https://tsplay.dev/WG6Bvm
1type IsStringLiteralType<T> = T extends string ? true : false;23type Test1 = IsStringLiteralType<string>; // true
We see that for string
we still return true
which is not right.
On the other hand, if we take string extends T
, that checks that we have only string
which is what we really want. Let's handle it differently for string
:
1type IsStringLiteralType<T> = T extends string2 ? string extends T3 ? false4 : true5 : false;
Now it looks ready and we can apply this knowledge for the last bug we had with Split
:
1type Split<2 S extends string,3 Separator extends string4> = S extends `${infer Word}${Separator}${infer Rest}`5 ? [Word, ...Split<Rest, Separator>]6 : S extends ""7 ? Separator extends ""8 ? []9 : [S]10 : string extends S11 ? S[]12 : [S];
We only use string extends S
as we already have a generic constrain for S
– S extends string
so we don't need to check that we have number
or boolean
Solution
1type Split<2 S extends string,3 Separator extends string4> = S extends `${infer Word}${Separator}${infer Rest}`5 ? [Word, ...Split<Rest, Separator>]6 : S extends ""7 ? Separator extends ""8 ? []9 : [S]10 : string extends S11 ? S[]12 : [S];
Let's sum up the whole solution:
- We iterate over the string from left to right, try to match the pattern with conditional type –
S extends `${infer Word}${Separator}${infer Rest}`
- We handle different edge cases: whether we have empty
S
or emptySeparator
- We don't forget about the passed string and handle it differently
To be able to check the whole solution with tests, please have a look at the Playground – https://tsplay.dev/mZaeoN
Thank you for the journey, see you soon! 👋
typescript