#2822Hard

Split

A type-level String.split() that turns a string type into a tuple of substrings. Reproducing JavaScript's odd empty-string rules is the hard part.

The well known split() method splits a string into an array of substrings by looking for a separator, and returns the new array. The goal of this challenge is to do the same in the type system. The happy path isn't the problem. What makes this one hard is faithfully reproducing JavaScript's quirky edge cases: splitting on '' yields individual characters, splitting '' itself behaves differently depending on the separator, and a missing separator returns the whole string untouched.

For example:

[object Object]

Challenge Instructions: Split

Hard

The well known split() method splits a string into an array of substrings by looking for a separator, and returns the new array. The goal of this challenge is to split a string, by using a separator, but in the type system!

For example:

[object Object]

View on GitHub: https://tsch.js.org/2822

Change the following code to make the test cases pass (no type check errors).

👋 Lifetime-License is leaving on August 10, 2026

Get it now for $29

One-time payment. Lifetime access to all pro challenges.

Loading...

Detailed Explanation

The finished type:

type Split<S extends string, SEP extends string = string> =
  string extends S
    ? string[]
    : string extends SEP
      ? [S]
      : S extends `${infer Left}${SEP}${infer Rest}`
        ? [Left, ...Split<Rest, SEP>]
        : SEP extends ''
          ? []
          : [S]

The core is a three-line recursion; everything else exists to nail the edge cases.

The core recursion

S extends `${infer Left}${SEP}${infer Rest}` searches S for the separator. Template literal inference is lazy, so Left captures the shortest prefix before the first occurrence of SEP, and Rest gets everything after it. We then emit Left and splice in the recursive result with a spread: [Left, ...Split<Rest, SEP>].

For Split<'The sine in cosine', 'in'> the first step evaluates to:

// Left = 'The s'
// Rest = 'e in cosine'
// result so far: ['The s', ...Split<'e in cosine', 'in'>]

Repeating this yields ['The s', 'e ', ' cos', 'e']. A multi-character separator that appears mid-word needs no extra code, because the pattern matches substrings, not just single characters.

When S no longer contains the separator, the pattern fails and we fall to the final [S], which wraps the last remaining chunk. That's how 'you?' ends the space-separated example, and how Split<'Hi! How are you?', 'z'> returns the whole string as ['Hi! How are you?'] in one step.

Splitting on the empty string

Split<'abc', ''> should return ['a', 'b', 'c'], just like in JavaScript. Interpolating SEP = '' into the pattern leaves `${infer Left}${infer Rest}`, two adjacent infer placeholders. TypeScript resolves this by giving the first placeholder exactly one character, so Left peels off a single character per step and the recursion walks the whole string character by character.

There's a catch at the end, though: when the recursion reaches '', the pattern `${infer Left}${infer Rest}` does not match the empty string (the first placeholder needs at least one character), so we'd fall through to [S] and produce a spurious trailing ''. That's why the fallback checks SEP extends '' ? [] : [S]:

The tests cover both of these deliberately. Same input string, opposite results, decided purely by the separator.

Handling a missing separator: string extends SEP

The tests also call Split<'Hi! How are you?'> with no separator at all, expecting ['Hi! How are you?'], mirroring str.split(undefined) in JavaScript. We default the parameter to the wide type: SEP extends string = string.

Now, how do you detect "the caller gave me string, not a literal"? The idiom is string extends SEP. For any literal like ' ' or 'z', string extends ' ' is false: the wide type is not assignable to a narrower literal. Only when SEP is string itself does the check pass, and then we return [S] unsplit. This "is it a wide string?" test is worth memorizing; it comes up constantly in type-level string code.

Handling a non-literal input: string extends S

The very first branch, string extends S ? string[], does the same trick for the input. If someone writes Split<string, 'whatever'>, there's no concrete string to inspect, so the honest answer is string[]: an array of unknown length with unknown contents. Without this guard the pattern match would misbehave on the wide type. Putting it first means every later branch can safely assume S is a concrete literal.

Why the order of branches matters

The branches run top to bottom, and each one narrows what the later ones must handle: wide S first, wide SEP second, then the recursive match, then the empty-separator fixup. Here's a concrete failure if you reorder: check the pattern before string extends SEP, and Split<'a b c'> (where SEP defaults to the wide string) would match `${infer Left}${string}${infer Rest}` and start slicing the input at arbitrary points instead of returning the honest ['a b c']. Type-level programs are just as sensitive to guard ordering as runtime ones.

This challenge is originally from here.

Share this challenge

Learn the Concepts