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