- 示例工程
【免费下载链接】type-challenges
Collection of TypeScript type challenges with online judge
本文围绕 type-challenges 第 114 号挑战(hard /#template-literal)展开,讲解如何用纯类型层面(零运行时代码)实现CamelCase<T>:将snake_case字符串类型转换为camelCase,并正确处理大写归一、连续下划线、尾随下划线、非字母字符与空串等边界情况。读完本文你将掌握模板字面量类型的递归逐字符匹配套路、Lowercase/Uppercase固有类型的正确用法,以及如何用@type-challenges/utils的Equal做严格类型断言验证。
题目速览:需求与难度定位
第 114 号挑战的英文原题(见 questions/00114-hard-camelcase/README.md)要求实现:
Implement
CamelCase<T>which convertssnake_casestring tocamelCase.
给出的两个示例:
type camelCase1 = CamelCase<'hello_world_with_types'> // expected to be 'helloWorldWithTypes' type camelCase2 = CamelCase<'HELLO_WORLD_WITH_TYPES'> // expected to be same as previous one值得注意的是第二个示例:全大写输入HELLO_WORLD_WITH_TYPES与全小写输入得到完全相同的结果。这意味着CamelCase<T>不只是“去掉下划线并把后一个字母大写”,它还要把整段字符串统一归一为小写——只有紧跟下划线后的那个字母保持大写。
从 questions/00114-hard-camelcase/info.yml 可以确认该题目的元数据:
difficulty: hard title: CamelCase tags: template-literal author: github: antfu name: Anthony Fu related: 612- 难度为 hard:难点不在递归本身,而在于对下划线“后面是什么字符”的分支判断,稍有不慎就会在连续下划线、尾随下划线、非字母场景上翻车;
- 标签为
template-literal:解法完全基于模板字面量类型; related: 612:与 medium 难度、由 Johnson Chu 提出的 KebabCase(612) 互为逆操作,本文最后会做对照分析。
你的起点 questions/00114-hard-camelcase/template.ts 只有一行占位:
type CamelCase<S extends string> = any从测试用例读出的行为规范:13 条边界用例
挑战的“验收标准”全部写在 questions/00114-hard-camelcase/test-cases.ts 中,共 13 条Equal断言。它们是本题最重要的需求文档,逐条拆解如下:
| 输入 | 期望输出 | 行为要点 |
|---|---|---|
'foobar' | 'foobar' | 不含下划线,原样保留(小写) |
'FOOBAR' | 'foobar' | 全大写需整体归一为小写 |
'foo_bar' | 'fooBar' | 基础场景:下划线删除,后一字母大写 |
'foo__bar' | 'foo_Bar' | 连续下划线:只保留一个_,且其后字母仍大写 |
'foo_$bar' | 'foo_$bar' | _后是非字母$:下划线保留、不触发大写 |
'foo_bar_' | 'fooBar_' | 尾随下划线:无后随字符,下划线保留 |
'foo_bar__' | 'fooBar__' | 两个尾随下划线都保留 |
'foo_bar_$' | 'fooBar_$' | 中间正常转换,_$整体原样保留 |
'foo_bar_hello_world' | 'fooBarHelloWorld' | 多段连续转换 |
'HELLO_WORLD_WITH_TYPES' | 'helloWorldWithTypes' | 大写输入与首例结果一致 |
'-' | '-' | 非字母单字符原样保留 |
'' | '' | 空串返回空串(递归终止) |
'😎' | '😎' | emoji 等非 ASCII 字符原样保留 |
综合这些用例可以归纳出四条硬性规则:
- 下划线
_本身不进入输出(除非它后面不是字母或后面没有字符); _后紧跟字母时:删除_,并把该字母转大写;- 普通字母一律归一为小写,包括全大写输入;
- 非字母字符(
$、-、emoji 等)原样保留,且它们前面的_不会被吞掉。
解题思路:单字符递归 + 下划线“触发大写”
模板字面量类型做字符串处理的核心套路是逐字符递归。用S extends${infer Head}${infer Rest}`` 把字符串拆成“第一个字符 + 剩余部分”,处理完Head后把Rest交给CamelCase自身继续递归;当字符串为空(无法匹配拆解模式)时直接返回S作为终止条件。
在此基础上,对Head分两条主路径:
- 普通字符:输出
Lowercase<Head>,继续处理剩余部分; - 下划线
_:窥视下一个字符Next,根据它的“身份”决定行为——这也正是 hard 难度的核心。
处理_时需要三个分支:
Next是字母 → 输出Uppercase<Next>(丢弃_),继续处理Tail;Next不是字母(如$、_)→ 保留_,原样输出Next,继续处理Tail;- 没有
Next(尾随下划线)→ 直接输出_。
其中“Next是字母”的判断不能靠Uppercase<Next> extends Next之类的朴素写法,需要专门设计一个IsLetter辅助类型。
源码级实现:辅助类型与主类型
第一步:判断单字符是否为字母的IsLetter
借助 TypeScript 的固有类型(intrinsic types)Lowercase与Uppercase可以做到对任意单字符的可靠判断:
type IsLetter<C extends string> = Lowercase<C> extends Uppercase<C> ? false : true原理:如果C是字母,那么它的大小写形式一定不同,即Lowercase<C>不 extendsUppercase<C>,结果为true;如果C是$、_、-、emoji 等没有大小写映射的字符,两者相等,结果为false。可以快速验证:
type T1 = IsLetter<'b'> // true type T2 = IsLetter<'B'> // true type T3 = IsLetter<'$'> // false type T4 = IsLetter<'_'> // false type T5 = IsLetter<'😎'> // false注意这里不能用C extends Uppercase<C>单向判断,因为小写字母bextends'B'为 false 却仍是字母;也不能用C extends Lowercase<C>,因为大写字母Bextends'b'同样为 false。只有“大小写映射不同”才是字母的充要条件。
第二步:CamelCase<S>主类型
type CamelCase<S extends string> = S extends `${infer Head}${infer Rest}` ? Head extends '_' ? Rest extends `${infer Next}${infer Tail}` ? Next extends '_' ? `_${CamelCase<`_${Tail}`>}` // 连续下划线:保留一个 _,重新进入 _ 处理 : IsLetter<Next> extends true ? `${Uppercase<Next>}${CamelCase<Tail>}` // 字母:丢弃 _,大写该字母 : `_${Next}${CamelCase<Tail>}` // 非字母:_ 与 Next 原样保留 : '_' // 尾随下划线 : `${Lowercase<Head>}${CamelCase<Rest>}` // 普通字符:归一为小写 : S相比朴素解法,这里特意增加了一层Next extends '_'分支来处理连续下划线。以foo__bar(期望foo_Bar)为例:当第一个_窥视到的Next是第二个_时,我们保留第一个_,然后把_Tail重新交给CamelCase递归,这样第二个_会再走一遍“下划线分支”,发现它后面是字母b,于是将其吞掉并大写为B,最终得到foo_Bar——与测试用例完全吻合。
关键路径逐步推演
用例一:'foo_bar_hello_world'→'fooBarHelloWorld'
f o o → 'foo'(逐字 Lowercase,原样) _ 后跟 b(字母) → 'B',丢弃 _ ar → 'ar' _ 后跟 h(字母) → 'H' ello_world 递归 → 'elloWorld'(w 同样被大写) 结果:'fooBarHelloWorld' ✓用例二:'HELLO_WORLD_WITH_TYPES'→'helloWorldWithTypes'
普通字符路径中的Lowercase<Head>会把每个大写字母逐个归一为小写,因此HELLO→hello;下划线后的大写字母W、T走Uppercase<Next>分支——Uppercase<'W'>仍是W,于是三处“首字母”保持大写,得到与全小写输入完全一致的结果。
用例三:'foo_$bar'→'foo_$bar'
_窥视到的Next是$,IsLetter<'$'>为 false,进入“非字母”分支输出_$,后面的bar原样递归,整体不做任何改写。
用例四:'foo_bar_'→'fooBar_'与''→''
foo_bar_中最后一个_没有Next,落入'_'分支保留尾随下划线;''无法匹配${infer Head}${infer Rest},直接返回S即空串,这也是整个递归的终止条件。
边界情况与设计取舍
为什么非字母后不触发大写:
Uppercase本身也能“接收”$、-、emoji(原样返回),如果直接无脑Uppercase<Next>,foo_$bar会被错误地吞掉_。引入IsLetter过滤,保证只有真正的字母才享受“吃掉下划线、切换大写”的待遇。连续下划线要“合二为一”:这是最容易写错的地方。朴素实现(对任何非字母都原样保留)会把
foo__bar变成foo__Bar,而期望是foo_Bar。只有把连续下划线“压缩”为单个_并让后续字母正常大写,才能通过第 4 条用例。全大写输入必须归一:第 2、10 条用例要求
CamelCase<'FOOBAR'>与CamelCase<'HELLO_WORLD_WITH_TYPES'>分别等于'foobar'和'helloWorldWithTypes',因此普通字符路径必须显式Lowercase<Head>,而不是原样拼接。空串与单字符:
''、'-'、'😎'三条用例保证了递归必须有明确的终止分支,且所有非下划线字符(包括 emoji 这类 BMP 之外的码点)都能原样通过——Lowercase/Uppercase对它们返回自身。
与 KebabCase(612)的互为逆操作
info.yml中的related: 612指向另一道字符串处理题 KebabCase(medium):把camelCase/PascalCase字符串替换为kebab-case,如FooBarBaz→foo-bar-baz。对比两道题的测试用例(见 questions/00612-medium-kebabcase/test-cases.ts)可以发现它们的设计哲学完全对称:
| 维度 | CamelCase(114,hard) | KebabCase(612,medium) |
|---|---|---|
| 转换方向 | snake_case→camelCase | camelCase/PascalCase→kebab-case |
| 字母处理 | 统一小写,_后字母大写 | 遇到大写字母前插入-并转小写 |
| 共享边界 | 空串、'-'、'😎'均原样返回 | 空串、'-'、'😎'均原样返回 |
| 输入归一 | 'FOOBAR'→'foobar' | 'ABC'→'a-b-c'(逐字母转小写) |
从源码结构看,两道题都以“模板字面量 + 单字符递归”为骨架,区别只在于对“下一个字符是大写字母”的识别逻辑:KebabCase 需要识别大写字母从而插入分隔符,CamelCase 需要识别下划线从而决定是否吞并并大写。KebabCase 比 CamelCase 少一个“连续分隔符压缩”的分支(Foo-Bar→foo--bar,连字符被完整保留),这也是 612 只有 medium 难度的原因之一。
如何验证你的实现
本题没有任何运行时测试,验证方式是类型层面的编译期断言。测试文件 questions/00114-hard-camelcase/test-cases.ts 从@type-challenges/utils引入Equal与Expect:
import type { Equal, Expect } from '@type-challenges/utils' type cases = [ Expect<Equal<CamelCase<'foobar'>, 'foobar'>>, Expect<Equal<CamelCase<'FOOBAR'>, 'foobar'>>, // ... 共 13 条 ]Equal的定义位于 utils/index.d.ts,采用经典的“函数签名逆变比较”技巧来判定两个类型是否严格相等:
export type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false只有当CamelCase<...>产出的字面量类型与期望字符串逐字精确一致(包括大小写与下划线位置)时,Equal才为true,Expect才通过编译。因此只要你的实现能通过这套断言,就说明它对 13 条边界用例的行为全部正确。本地验证方式为在仓库根目录安装依赖(pnpm install)后,让 TypeScript 编译器检查该测试文件即可(项目使用 pnpm workspace,@type-challenges/utils以workspace:*形式声明于 package.json)。整个仓库的所有挑战题目都遵循同一套template.ts+test-cases.ts+info.yml结构,理解 114 题的解法后,你可以把同样的递归模式迁移到其他模板字面量类题目中。
小结
CamelCase<T>看似只是“去掉下划线、后面字母大写”,但 13 条测试用例揭示出远超直觉的细节:大小写归一、连续下划线压缩、尾随下划线保留、非字母防误吞。本文给出的实现通过“单字符递归 +IsLetter分支判断 + 连续下划线特殊处理”三块拼图完整覆盖了全部用例,并借助@type-challenges/utils的Equal完成了编译期验证。建议你亲自在 template.ts 中从type CamelCase<S extends string> = any起步,先自行尝试,再对照本文实现推演foo__bar、HELLO_WORLD_WITH_TYPES等关键路径,最后挑战它的“逆操作” KebabCase(612) 检验举一反三的能力。
- 示例工程
【免费下载链接】type-challenges
Collection of TypeScript type challenges with online judge
相关推荐
type-challenges 中等题精解:用模板字面量类型实现 `Capitalize<T>`
type challenges 中等题精解:用模板字面量类型实现 Capitalize<T 本篇文章基于开源仓库 type challenges(Collect
示例工程type-challenges 题解:用模板字面量类型实现 `Capitalize<T>`,将字符串首字母大写
type challenges 题解:用模板字面量类型实现 Capitalize<T ,将字符串首字母大写 导读 本篇文章围绕 type challenges
示例工程type-challenges 题解(110/medium):用模板字面量类型实现 `Capitalize<T>` 字符串首字母大写
type challenges 题解(110/medium):用模板字面量类型实现 Capitalize<T 字符串首字母大写 Capitalize 是 typ
示例工程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考