beraliv

Split string literal type in TypeScript

Example of Split use
1type Split<S extends string, Separator extends string> = any; // implementation
2
3type 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.

Iteration over the tuple
1type Split<
2 S extends string,
3 Separator extends string
4> = 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.

Checking the cases where separator is not a part of the string
1type Split<
2 S extends string,
3 Separator extends string
4> = S extends `${infer Word}${Separator}${infer Rest}`
5 ? [Word, ...Split<Rest, Separator>]
6 : [];
7
8type 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.

Return [S] at the last iteration
1type Split<
2 S extends string,
3 Separator extends string
4> = 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.

Checking cases where we have empty string at the end of tuple
1type Split<
2 S extends string,
3 Separator extends string
4> = S extends `${infer Word}${Separator}${infer Rest}`
5 ? [Word, ...Split<Rest, Separator>]
6 : [S];
7
8// ["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

Last iteration with empty string
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:

Handle empty string S
1type Split<
2 S extends string,
3 Separator extends string
4> = 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.

Empty string S for non-empty separator
1type Split<
2 S extends string,
3 Separator extends string
4> = S extends `${infer Word}${Separator}${infer Rest}`
5 ? [Word, ...Split<Rest, Separator>]
6 : S extends ""
7 ? []
8 : [S];
9
10type 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

JavaScript example with empty string and non-empty separator
1"".split("z"); // [""]

Let's handle this edge case as well.

Handle empty string S with non-empty separator
1type Split<
2 S extends string,
3 Separator extends string
4> = 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 string
4> = S extends `${infer Word}${Separator}${infer Rest}`
5 ? [Word, ...Split<Rest, Separator>]
6 : S extends ""
7 ? Separator extends ""
8 ? []
9 : [S]
10 : [S];
11
12type 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:

Difference between string and string literal type
1type IsStringLiteralType<T> = any; // implementation
2
3type 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.

Check if it's a string or string literal type
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

Incorrect case for string
1type IsStringLiteralType<T> = T extends string ? true : false;
2
3type 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:

Final solution for IsStringLiteralType
1type IsStringLiteralType<T> = T extends string
2 ? string extends T
3 ? false
4 : true
5 : false;

Now it looks ready and we can apply this knowledge for the last bug we had with Split:

Final solution for Split
1type Split<
2 S extends string,
3 Separator extends string
4> = S extends `${infer Word}${Separator}${infer Rest}`
5 ? [Word, ...Split<Rest, Separator>]
6 : S extends ""
7 ? Separator extends ""
8 ? []
9 : [S]
10 : string extends S
11 ? 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

Solution
1type Split<
2 S extends string,
3 Separator extends string
4> = S extends `${infer Word}${Separator}${infer Rest}`
5 ? [Word, ...Split<Rest, Separator>]
6 : S extends ""
7 ? Separator extends ""
8 ? []
9 : [S]
10 : string extends S
11 ? S[]
12 : [S];

Let's sum up the whole solution:

  1. We iterate over the string from left to right, try to match the pattern with conditional type โ€“ย S extends `${infer Word}${Separator}${infer Rest}`
  2. We handle different edge cases: whether we have empty S or empty Separator
  3. 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

Comments

Alexey Berezin profile image

Written by Alexey Berezin who loves London ๐Ÿด๓ ง๓ ข๓ ฅ๓ ฎ๓ ง๓ ฟ, players โฏ and TypeScript ๐Ÿฆบ Follow me on Twitter