第 15 章 · 字符串体操
- 用「模板 + infer」把字符串逐字拆开,再按规则重组
- 实现
Split/Join:字符串与元组的双向转换 - 实现命名风格转换:
KebabCase、CamelCase - 实现
StartsWith/EndsWith判定 - 掌握「大小写边界」「下划线处理」这类字符串解析的细节陷阱
字符串在类型层面就是一个字符元组。一切字符串体操 = 「按模式拆 → 递归处理 → 按规则拼回」。第 8 章打的基础,本章用到极致。
15.1 逐字解析:把字符串当元组遍历
15.1.1 拆单个字符
type Chars<S extends string> =
S extends `${infer F}${infer Rest}` ? [F, ...Chars<Rest>] : []
type A = Chars<'abc'> // ['a', 'b', 'c']
${infer F}${infer Rest} 中,F 取一个字符,Rest 取剩余——这是字符串递归的「拆一步」。
模板里的 infer F 按 UTF-16 码元拆。emoji(如 😎 占两个码元)会被拆成两半。处理这类字符时要注意——第 1-8 章的 Capitalize 测试里就专门有 emoji 用例。
15.1.2 逐字符变换
// 全大写
type Upper<S extends string> =
S extends `${infer F}${infer Rest}`
? `${Uppercase<F>}${Upper<Rest>}`
: S
type A = Upper<'hello'> // 'HELLO'
15.2 Split 与 Join:字符串 ⇄ 元组
15.2.1 Split:按分隔符拆成元组
type Split<S extends string, SEP extends string = never> =
S extends `${infer L}${SEP}${infer R}`
? [L, ...Split<R, SEP>]
: [S]
type A = Split<'a-b-c', '-'> // ['a', 'b', 'c']
type B = Split<'a-b-c', ''> // ['a', '-', 'b', '-', 'c'](空分隔符逐字符)
- 默认参数:
Split<'abc'>不带分隔符时,SEP需要默认值,否则报「缺参数」。本书用= never(表示「没有分隔符」),此时整个字符串作为一项返回。 - 空分隔符:
Split<'abc', ''>要逐字符拆,但''作为分隔符在模板里行为特殊(''会匹配空、导致死循环)。所以必须先把SEP为空的情况单独处理——02822 的测试专门卡这个,参考答案里也专门有SEP extends ''的分支。
15.2.2 Join:元组拼成字符串
type Join<T extends unknown[], U extends string | number = ','> =
T extends [infer F, ...infer R]
? F extends string | number
? R extends []
? `${F}`
: `${F}${U}${Join<R, U>}`
: ''
: ''
type A = Join<['a', 'b', 'c'], '-'> // 'a-b-c'
type B = Join<['a', 'b', 'c']> // 'a,b,c'(默认分隔符)
type C = Join<[], '-'> // ''
F extends string | number:元素必须是能拼进字符串的类型R extends []:最后一项后面不加分隔符- 空数组返回
''
15.3 命名风格转换:KebabCase / CamelCase
15.3.1 KebabCase:大写字母 → -小写
'FooBarBaz' → 'foo-bar-baz'。核心是识别「大写字母」并插入 -:
type KebabCase<S extends string> =
S extends `${infer C}${infer R}`
? R extends Uncapitalize<R> // R 首字符是小写?
? `${Uncapitalize<C>}${KebabCase<R>}` // 是 → 不加 -
: `${Uncapitalize<C>}-${KebabCase<R>}` // 否 → R 以大写开头,加 -
: S
type A = KebabCase<'FooBarBaz'> // 'foo-bar-baz'
type B = KebabCase<'ABC'> // 'a-b-c'
R extends Uncapitalize<R>:把 R 的首字母转小写,若 R 没变(R 首字符本来就小写或非字母),说明没有边界;若变了(R 首字符是大写),说明这里该插 -。这是「识别大小写边界」的标准手法。
15.3.2 CamelCase:下划线 + 字母 → 大写
'foo_bar_hello' → 'fooBarHello'。比 KebabCase 多一个「下划线后跟字母才是分隔」的判定:
// 判断一个字符是不是字母(第 8 章)
type IsLetter<C extends string> =
C extends Uppercase<C>
? C extends Lowercase<C> ? false : true
: true
type CamelCase<S extends string> =
S extends `${infer F}${infer R}`
? F extends '_'
? R extends `${infer N}${infer T}`
? IsLetter<N> extends true
? `${Capitalize<N>}${CamelCase<T>}` // _ + 字母 → 吃掉 _,大写字母
: `${F}${CamelCase<R>}` // _ + 非字母 → 保留 _
: F
: `${Lowercase<F>}${CamelCase<R>}` // 普通字符 → 小写,继续
: S
type A = CamelCase<'foo_bar'> // 'fooBar'
type B = CamelCase<'foo__bar'> // 'foo_Bar'(双下划线只吃掉一个)
'foo__bar' 应该得到 'foo_Bar'——第一个 _ 后面跟的是 _(非字母),所以保留;第二个 _ 后面跟 b(字母),所以吃掉并大写。这就是 IsLetter 判定存在的原因。00114 CamelCase 专门卡这个边界。
15.4 StartsWith / EndsWith
第 8 章写过,这里是完整版(含 '' 边界):
type StartsWith<T extends string, U extends string> =
T extends `${U}${infer R}` ? true : false
type EndsWith<T extends string, U extends string> =
T extends `${infer L}${U}` ? true : false
type A = StartsWith<'foobar', 'foo'> // true
type B = StartsWith<'foobar', ''> // true(空串是任何串的前缀)
type C = EndsWith<'foobar', 'bar'> // true
type D = EndsWith<'foobar', ''> // true
本章小结
- 字符串 = 字符元组;
${infer F}${infer R}是「拆一步」 Split/Join做字符串 ⇄ 元组转换,注意空分隔符与默认参数两个坑KebabCase用「R 是否以大写开头」插-;CamelCase用IsLetter区分「_ + 字母」与「_ + 非字母」StartsWith/EndsWith就是模板匹配,''是任何串的前缀/后缀
本章练习
挑战题
| 题号 | 题目 | 难度 | 考察点 |
|---|---|---|---|
| 02822 | Split | hard | 空分隔符 + 默认参数 |
| 05310 | Join | medium | 元组拼串 |
| 00612 | KebabCase | medium | 大写边界 |
| 00114 | CamelCase | hard | 下划线 + IsLetter |
| 02688 | StartsWith | medium | 前缀匹配 |
| 02693 | EndsWith | medium | 后缀匹配 |
代码练习
-
Chars:实现
type Chars<S>把字符串拆成字符元组(15.1.1),验证Chars<'hi'>是['h', 'i']。 -
Upper / Lower:分别实现
Upper和Lower(逐字符 +Uppercase/Lowercase),验证 emoji 会被拆成两段(观察现象即可)。 -
Split 变体:给
Split处理Split<''>(空串无分隔符)返回['']、Split<'', ''>返回[](参考 02822 的边界)。 -
KebabCase 练习:实现
KebabCase,验证'fooBarBaz'→'foo-bar-baz'、'Foo-Bar'→'foo--bar'(连字符会被保留导致双横线,观察这个细节)。 -
CamelCase 反向:实现
type KebabToCamel<S>('foo-bar'→'fooBar'),提示:先按-拆,再逐段Capitalize拼接。