TypeChallenge 612: KebabCase
KebabCase
Problem
Convert a string from camelCase or PascalCase to kebab-case.
type FooBarBaz = KebabCase<"FooBarBaz">
// Expected: "foo-bar-baz"
type DoNothing = KebabCase<"do-nothing">
// Expected: "do-nothing"Test cases:
type cases = [
Expect<Equal<KebabCase<'FooBarBaz'>, 'foo-bar-baz'>>,
Expect<Equal<KebabCase<'fooBarBaz'>, 'foo-bar-baz'>>,
Expect<Equal<KebabCase<'foo-bar'>, 'foo-bar'>>,
Expect<Equal<KebabCase<'foo_bar'>, 'foo_bar'>>,
Expect<Equal<KebabCase<'Foo-Bar'>, 'foo--bar'>>,
Expect<Equal<KebabCase<'ABC'>, 'a-b-c'>>,
Expect<Equal<KebabCase<'-'>, '-'>>,
Expect<Equal<KebabCase<''>, ''>>,
Expect<Equal<KebabCase<'😎'>, '😎'>>,
]Solution
type KebabCase<S extends string> = S extends `${infer First}${infer Rest}`
? Rest extends Uncapitalize<Rest>
? `${Uncapitalize<First>}${KebabCase<Rest>}`
: `${Uncapitalize<First>}-${KebabCase<Rest>}`
: SDeep Dive
The Core Insight
The key observation is that we need to insert a hyphen before each uppercase letter (except the first one), then lowercase everything.
The trick is detecting uppercase letters. TypeScript provides Uncapitalize<T> which lowercases the first character of a string. We can use this for comparison:
// If Rest starts with uppercase, Rest !== Uncapitalize<Rest>
Rest extends Uncapitalize<Rest> // false if Rest starts with uppercaseStep-by-Step Breakdown
Let's trace through KebabCase<'FooBar'>:
First iteration:
First = 'F',Rest = 'ooBar''ooBar' extends Uncapitalize<'ooBar'>→'ooBar' extends 'ooBar'→ ✅ true- Result:
'f' + KebabCase<'ooBar'>
Processing 'ooBar':
First = 'o',Rest = 'oBar''oBar' extends 'oBar'→ ✅ true- Result:
'o' + KebabCase<'oBar'>
Processing 'oBar':
First = 'o',Rest = 'Bar''Bar' extends Uncapitalize<'Bar'>→'Bar' extends 'bar'→ ❌ false- Result:
'o' + '-' + KebabCase<'Bar'>
Processing 'Bar':
First = 'B',Rest = 'ar''ar' extends 'ar'→ ✅ true- Result:
'b' + KebabCase<'ar'>
Processing 'ar':
First = 'a',Rest = 'r''r' extends 'r'→ ✅ true- Result:
'a' + KebabCase<'r'>
Processing 'r':
First = 'r',Rest = '''' extends ''→ ✅ true- Result:
'r' + KebabCase<''>
Base case:
KebabCase<''>returns''
Final: 'f' + 'o' + 'o' + '-' + 'b' + 'a' + 'r' + '' = 'foo-bar'
Why This Handles Edge Cases
Already kebab-case ('foo-bar'):
- The hyphen
-is not uppercase, soUncapitalize<'-bar'>equals'-bar' - No extra hyphens inserted
Consecutive uppercase ('ABC'):
- Each uppercase triggers the hyphen branch
'A'→'a', then'B'is uppercase →'-b', then'C'is uppercase →'-c'- Result:
'a-b-c'
Non-letter characters ('😎', '-'):
Uncapitalizehas no effect on non-letters- They pass through unchanged
Alternative Approach
Some solutions use Lowercase instead:
type KebabCase<S extends string> = S extends `${infer F}${infer R}`
? R extends Uncapitalize<R>
? `${Lowercase<F>}${KebabCase<R>}`
: `${Lowercase<F>}-${KebabCase<R>}`
: SBoth work identically here since we process one character at a time. Uncapitalize and Lowercase behave the same for single characters.
Key Takeaways
Uncapitalize<T>as uppercase detector: ComparingS extends Uncapitalize<S>tells you if a string starts with lowercase (or non-letter)Recursive template literal types: Process strings character-by-character with
${infer First}${infer Rest}Conditional hyphen insertion: Insert the hyphen before processing the rest, not after
Built-in string utilities: TypeScript's
Uppercase,Lowercase,Capitalize, andUncapitalizeare invaluable for string manipulationEdge case handling: The solution naturally handles empty strings, non-letters, and already-formatted strings without special cases
