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]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).
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.
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'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.
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'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 ''.
'FOOBAR' → 'FOOBAR'. The challenge says to leave everything except the first letter as-is. Capitalize only touches the first character, so an already-uppercase word survives, and this is why the solution never lowercases anything.'aa!bb@cc#dd$ee%...': every exotic separator works automatically, because the letter check is based on casing behavior rather than a list of characters.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.