Yuzhe's Blog

yuzhes

TypeChallenge 612: KebabCase

KebabCase

Challenge Link

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>}`
  : S

Deep 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 uppercase

Step-by-Step Breakdown

Let’s trace through KebabCase<'FooBar'>:

  1. First iteration: First = 'F', Rest = 'ooBar'

    • 'ooBar' extends Uncapitalize<'ooBar'>'ooBar' extends 'ooBar' → ✅ true
    • Result: 'f' + KebabCase<'ooBar'>
  2. Processing ‘ooBar’: First = 'o', Rest = 'oBar'

    • 'oBar' extends 'oBar' → ✅ true
    • Result: 'o' + KebabCase<'oBar'>
  3. Processing ‘oBar’: First = 'o', Rest = 'Bar'

    • 'Bar' extends Uncapitalize<'Bar'>'Bar' extends 'bar' → ❌ false
    • Result: 'o' + '-' + KebabCase<'Bar'>
  4. Processing ‘Bar’: First = 'B', Rest = 'ar'

    • 'ar' extends 'ar' → ✅ true
    • Result: 'b' + KebabCase<'ar'>
  5. Processing ‘ar’: First = 'a', Rest = 'r'

    • 'r' extends 'r' → ✅ true
    • Result: 'a' + KebabCase<'r'>
  6. Processing ‘r’: First = 'r', Rest = ''

    • '' extends '' → ✅ true
    • Result: 'r' + KebabCase<''>
  7. Base case: KebabCase<''> returns ''

Final: 'f' + 'o' + 'o' + '-' + 'b' + 'a' + 'r' + '' = 'foo-bar'

Why This Handles Edge Cases

Already kebab-case ('foo-bar'):

Consecutive uppercase ('ABC'):

Non-letter characters ('😎', '-'):

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>}`
  : S

Both work identically here since we process one character at a time. Uncapitalize and Lowercase behave the same for single characters.

Key Takeaways

  1. Uncapitalize<T> as uppercase detector: Comparing S extends Uncapitalize<S> tells you if a string starts with lowercase (or non-letter)

  2. Recursive template literal types: Process strings character-by-character with ${infer First}${infer Rest}

  3. Conditional hyphen insertion: Insert the hyphen before processing the rest, not after

  4. Built-in string utilities: TypeScript’s Uppercase, Lowercase, Capitalize, and Uncapitalize are invaluable for string manipulation

  5. Edge case handling: The solution naturally handles empty strings, non-letters, and already-formatted strings without special cases