#112Hard

Capitalize Words

Uppercase the first letter of every word in a string type, where a word boundary is any character without an upper or lower case form.

The hard part of title-casing a string type is deciding what counts as a word boundary.

A boundary in this challenge is not just a space. CapitalizeWords<T> must uppercase the first letter of each word and leave everything else alone, and words can be separated by any non-letter character, punctuation and emoji included. The solution walks the string one character at a time with an accumulator, plus a small trick for detecting whether a character is a letter at all.

For example

[object Object]

Challenge Instructions: Capitalize Words

Hard

Implement CapitalizeWords<T> which converts the first letter of each word of a string to uppercase and leaves the rest as-is.

For example

[object Object]

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

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

Loading...

Detailed Explanation

The whole thing is one recursive type:

type CapitalizeWords<
  S extends string,
  Word extends string = '',
> = S extends `${infer Ch}${infer Rest}`
  ? Uppercase<Ch> extends Lowercase<Ch>
    ? `${Capitalize<Word>}${Ch}${CapitalizeWords<Rest>}`
    : CapitalizeWords<Rest, `${Word}${Ch}`>
  : Capitalize<Word>

The naive approach, split on spaces and capitalize each piece, fails immediately on the test suite, because 'foo bar.hello,world' must become 'Foo Bar.Hello,World'. Words can be separated by ., ,, !, @, even emoji. So instead of looking for specific separators, the solution walks the string one character at a time and asks a different question: is this character a letter.

Walking the string with an accumulator

The second type parameter Word extends string = '' is an accumulator: a private scratch variable with a default value, so callers still write CapitalizeWords<'foo bar'> with a single argument. As the recursion consumes the input S, it collects consecutive letters into Word until it hits something that isn't a letter.

The pattern `${infer Ch}${infer Rest}` peels off exactly one character: when two infer placeholders are adjacent in a template literal, TypeScript assigns a single character to the first one and the remainder to the second. For 'foo bar' the first step gives:

// Ch   = 'f'
// Rest = 'oo bar'

Detecting a non-letter character

Uppercase<Ch> extends Lowercase<Ch> is the letter detector. For a letter, uppercasing and lowercasing produce different strings ('F' vs 'f'), so the check fails. For anything without a case, like ' ', '.', '@' or an emoji, both utilities return the character unchanged, the two sides are identical, and the check succeeds. No hardcoded separator list needed.

The two branches

When Ch is a letter (the second branch), nothing is emitted yet. We just recurse on Rest with the letter appended to the accumulator: CapitalizeWords<Rest, `${Word}${Ch}`>. After three steps of 'foo bar', Word is 'foo' and S is ' bar'.

When Ch is a separator (the first branch), the current word is finished. We emit `${Capitalize<Word>}${Ch}`, the collected word with its first letter uppercased followed by the separator itself, and recurse on Rest with a fresh accumulator (note the recursion passes only one argument, so Word resets to '').

Tracing 'foo bar' end to end:

CapitalizeWords<'foo bar'>       // Word = ''
// ...collect 'f', 'o', 'o'...
CapitalizeWords<' bar', 'foo'>   // ' ' is a separator
// emits 'Foo ' and recurses fresh:
`Foo ${CapitalizeWords<'bar'>}`  // collects 'bar', input runs out
// base case: Capitalize<'bar'> = 'Bar'
// result: 'Foo Bar'

The base case

When S no longer matches `${infer Ch}${infer Rest}`, the input is empty, but the accumulator may still hold the final word. Returning Capitalize<Word> flushes it. This also covers the empty-string test: CapitalizeWords<''> never recurses, Word is '', and Capitalize<''> is ''.

Edge cases the tests cover

The accumulator pattern used here, collecting state in an extra defaulted type parameter and flushing it at boundaries, shows up all over advanced type-level programming, from string splitting to type-level parsers.

This challenge is originally from here.

Share this challenge

Learn the Concepts