Yuzhe's Blog

yuzhes

TypeChallenge 612: KebabCase

KebabCase

题目链接

题目

camelCasePascalCase 字符串转换为 kebab-case

type FooBarBaz = KebabCase<"FooBarBaz">
// 期望: "foo-bar-baz"

type DoNothing = KebabCase<"do-nothing">
// 期望: "do-nothing"

测试用例:

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<'😎'>, '😎'>>,
]

解答

type KebabCase<S extends string> = S extends `${infer First}${infer Rest}`
  ? Rest extends Uncapitalize<Rest>
    ? `${Uncapitalize<First>}${KebabCase<Rest>}`
    : `${Uncapitalize<First>}-${KebabCase<Rest>}`
  : S

深入解析

核心思路

关键观察:我们需要在每个大写字母之前插入连字符(首字母除外),然后全部转小写。

检测大写字母的技巧是利用 TypeScript 的 Uncapitalize<T>,它会将字符串首字符转为小写。我们可以用它来比较:

// 如果 Rest 以大写开头,Rest !== Uncapitalize<Rest>
Rest extends Uncapitalize<Rest>  // 如果 Rest 以大写开头则为 false

逐步拆解

让我们追踪 KebabCase<'FooBar'> 的执行过程:

  1. 第一次迭代: First = 'F', Rest = 'ooBar'

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

    • 'oBar' extends 'oBar' → ✅ true
    • 结果: 'o' + KebabCase<'oBar'>
  3. 处理 ‘oBar’: First = 'o', Rest = 'Bar'

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

    • 'ar' extends 'ar' → ✅ true
    • 结果: 'b' + KebabCase<'ar'>
  5. 处理 ‘ar’: First = 'a', Rest = 'r'

    • 'r' extends 'r' → ✅ true
    • 结果: 'a' + KebabCase<'r'>
  6. 处理 ‘r’: First = 'r', Rest = ''

    • '' extends '' → ✅ true
    • 结果: 'r' + KebabCase<''>
  7. 基础情况: KebabCase<''> 返回 ''

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

边界情况处理

已经是 kebab-case ('foo-bar'):

连续大写 ('ABC'):

非字母字符 ('😎', '-'):

替代方案

有些解法使用 Lowercase 代替:

type KebabCase<S extends string> = S extends `${infer F}${infer R}`
  ? R extends Uncapitalize<R>
    ? `${Lowercase<F>}${KebabCase<R>}`
    : `${Lowercase<F>}-${KebabCase<R>}`
  : S

两者在这里效果相同,因为我们每次只处理一个字符。对于单个字符,UncapitalizeLowercase 行为一致。

关键要点

  1. Uncapitalize<T> 作为大写检测器: 比较 S extends Uncapitalize<S> 可以判断字符串是否以小写(或非字母)开头

  2. 递归模板字面量类型: 使用 ${infer First}${infer Rest} 逐字符处理字符串

  3. 条件连字符插入: 在处理剩余部分之前插入连字符,而不是之后

  4. 内置字符串工具: TypeScript 的 UppercaseLowercaseCapitalizeUncapitalize 是字符串操作的利器

  5. 边界情况处理: 该方案无需特殊处理就能自然应对空字符串、非字母和已格式化的字符串