拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

type-challenges 题解 114:用模板字面量类型实现 `CamelCase<T>`,完成 snake_case 到 camelCase 的转换

type-challenges 题解 114:用模板字面量类型实现 `CamelCase<T>`,完成 snake_case 到 camelCase 的转换
  • 示例工程

【免费下载链接】type-challenges

Collection of TypeScript type challenges with online judge

项目地址:https://gitcode.com/GitHub_Trending/ty/type-challenges
点击查看免费下载

本文围绕 type-challenges 第 114 号挑战(hard /#template-literal)展开,讲解如何用纯类型层面(零运行时代码)实现CamelCase<T>:将snake_case字符串类型转换为camelCase,并正确处理大写归一、连续下划线、尾随下划线、非字母字符与空串等边界情况。读完本文你将掌握模板字面量类型的递归逐字符匹配套路、Lowercase/Uppercase固有类型的正确用法,以及如何用@type-challenges/utils的Equal做严格类型断言验证。

题目速览:需求与难度定位

第 114 号挑战的英文原题(见 questions/00114-hard-camelcase/README.md)要求实现:

ImplementCamelCase<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 字符原样保留

综合这些用例可以归纳出四条硬性规则:

  1. 下划线_本身不进入输出(除非它后面不是字母或后面没有字符);
  2. _后紧跟字母时:删除_,并把该字母转大写;
  3. 普通字母一律归一为小写,包括全大写输入;
  4. 非字母字符($、-、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即空串,这也是整个递归的终止条件。

边界情况与设计取舍

  1. 为什么非字母后不触发大写:Uppercase本身也能“接收”$、-、emoji(原样返回),如果直接无脑Uppercase<Next>,foo_$bar会被错误地吞掉_。引入IsLetter过滤,保证只有真正的字母才享受“吃掉下划线、切换大写”的待遇。

  2. 连续下划线要“合二为一”:这是最容易写错的地方。朴素实现(对任何非字母都原样保留)会把foo__bar变成foo__Bar,而期望是foo_Bar。只有把连续下划线“压缩”为单个_并让后续字母正常大写,才能通过第 4 条用例。

  3. 全大写输入必须归一:第 2、10 条用例要求CamelCase<'FOOBAR'>与CamelCase<'HELLO_WORLD_WITH_TYPES'>分别等于'foobar'和'helloWorldWithTypes',因此普通字符路径必须显式Lowercase<Head>,而不是原样拼接。

  4. 空串与单字符:''、'-'、'😎'三条用例保证了递归必须有明确的终止分支,且所有非下划线字符(包括 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→camelCasecamelCase/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

项目地址:https://gitcode.com/GitHub_Trending/ty/type-challenges
点击查看免费下载
上一篇:芋道源码终极指南:Spring Boot企业级开发框架实战教程
下一篇:芋道源码:Spring Boot企业级开发框架终极指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

返回列表