TypeScript 字符串体操 — 模板字面量与 infer 的组合,类型层的正则引擎
昨天的递归是"引擎",今天给它配上"赛道":字符串。核心认知一句话——字符串 ≈ 可以啃的元组:元组用
[infer F, ...infer R]拆头尾,字符串用`${infer F}${infer R}`啃首字符。Trim、Replace、ReplaceAll 这些运行时字符串方法,全部可以搬到类型层;再配合联合模式的"或匹配",你将拥有一个不写一行运行时代码的"类型正则引擎"。
目录
一、核心心法:字符串是可以啃的元组
1.1 两大容器的同构性
// 元组的拆分(Day 16):
type Head<T extends readonly any[]> = T extends [infer F, ...any[]] ? F : never;
type Tail<T extends readonly any[]> = T extends [any, ...infer R] ? R : [];
// 字符串完全同构(把 [] 换成 ``):
type FirstChar<S extends string> =
S extends `${infer F}${infer R}` ? F : never;
type RestStr<S extends string> =
S extends `${infer F}${infer R}` ? R : never;
type A = FirstChar<"hello">; // "h"
type B = RestStr<"hello">; // "ello"
// ⭐ 元组会做的题,字符串都能做:
// 取头/取尾 = 首字符/剩余
// 递归扫描 = 逐字符啃
// Length(字符串版)= 递归计数(今天练习)
1.2 单字符的非贪婪特性
// 重要:${infer F} 的 F 只匹配【一个字符】(非贪婪)!
type S = "abc" extends `${infer A}${infer B}` ? [A, B] : never;
// S = ["a", "bc"] —— 不是 ["ab", "c"]!
// 为什么?TS 的推断规则:模式中"最后一段"的 infer 吃掉剩余,
// 前面的每个 ${infer X} 恰好匹配一个字符
// 这是特性不是 bug——它让"逐字符递归"成为可能
// 想要"贪婪"必须显式给边界:
type Split2<S extends string> =
S extends `${infer A}.${infer B}` ? [A, ...Split2<B>] : [S];
// ↑ 字面量 "." 是显式边界:A 吃到点号前为止
type C = Split2<"a.b.c">; // ["a", "b", "c"]
1.3 字符串元操作速查
// 啃首字符
type DropFirst<S extends string> =
S extends `${string}${infer R}` ? R : S;
// 拼接(值的模板串直接类型化)
type Join<A extends string, B extends string> = `${A}${B}`;
// 判空前缀
type StartsWith<S extends string, P extends string> =
S extends `${P}${string}` ? true : false;
type EndsWith<S extends string, P extends string> =
S extends `${string}${P}` ? true : false;
type A1 = StartsWith<"device:update", "device:">; // true
type A2 = EndsWith<"device:update", "update">; // true
// ⭐ StartsWith/EndsWith 是内置字符串工具的同款逻辑——一发命中不用递归
二、模式匹配进阶:联合模式与多段捕获
2.1 联合模式:一个条件匹配多种形状
// extends 右侧可以是"模式的联合"——匹配任一形状:
type TrimOne<S extends string> =
S extends ` ${infer R}` | `${infer R} ` ? R : S;
// ↑ 左空格开头 ↑ 右空格结尾
// 匹配任意一种 → 捕获各自的 R
type A = TrimOne<" hi">; // "hi"
type B = TrimOne<"hi ">; // "hi"
type C = TrimOne<"hi">; // "hi"(不匹配 → 原样返回)
// ⚠ 注意:两侧都有空格时 " hi " 匹配哪个?
// TS 按模式顺序尝试——匹配 ` ${infer R}` 得 "hi "(只去了一侧)
// 想两侧全去 → 递归(今天的 Trim 题)
2.2 多段捕获:一个模式抓多个变量
// 模板串里可以放多个 infer,各抓各的:
type Parse<S extends string> =
S extends `${infer Domain}:${infer Action}:${infer Target}`
? { domain: Domain; action: Action; target: Target }
: never;
type P = Parse<"device:update:cnc">;
// { domain: "device"; action: "update"; target: "cnc" } ⭐
// ⚠ 分隔符的数量决定捕获段数:
// 两个 ":" 把字符串分成三段,三个 infer 各得一段
// 多余的 ":" 会被吸进最后一段(贪婪侧在尾部)
type Q = Parse<"a:b:c:d">;
// { domain: "a"; action: "b"; target: "c:d" } —— target 吸了剩余
2.3 字面量与 infer 混排
// 模式里字面量和 infer 可以任意混排——这就是"类型正则":
type Patterns =
| `${"http" | "https"}://${string}` // 协议前缀
| `ws://${string}:${number}` // WebSocket 地址
| `${string}:${number}/${string}`; // host:port/path
type U1 = "https://factory.cn" extends `${"http" | "https"}://${string}` ? true : false;
// true —— 联合放模式里 = "或"匹配
// 这就是"正则引擎"的雏形:字面量 = 字面匹配、infer = 捕获组、
// 联合 = 交替(alternation)、递归 = 量词(*/+)
三、今日题目逐个击破
3.1 第 106 题 · TrimLeft(easy)
// 题目:TrimLeft<" hello"> → "hello"(去左侧所有空白)
type TrimLeft<S extends string> =
S extends ` ${infer Rest}` ? TrimLeft<Rest> : S;
// ↑ 啃一个空格,递归;非空格开头 → 终止
// 用例:
type _1 = Expect<Equal<TrimLeft<"str">, "str">>;
type _2 = Expect<Equal<TrimLeft<" str">, "str">>;
type _3 = Expect<Equal<TrimLeft<" \n\t">, "">>;
// ⚠ 用例含 \n \t——需要扩展空白模式:
type TrimLeftFull<S extends string> =
S extends `${" " | "\n" | "\t"}${infer Rest}` ? TrimLeftFull<Rest> : S;
// 联合模式:空格/换行/制表符任一都啃 ⭐
3.2 第 108 题 · Trim(medium)
// 题目:Trim<" hi "> → "hi"(两侧所有空白)
// ===== 先自己写 15 分钟 =====
// 思路提示(三板斧):
// 形状:字符串 → 字符串(两侧裁剪)
// 拆法:联合模式(左侧或右侧空白任一)
// 递归:需要(空白数量未知)
type Trim<S extends string> =
S extends `${" " | "\n" | "\t"}${infer Rest}` | `${infer Rest}${" " | "\n" | "\t"}`
? Trim<Rest>
: S;
// 用例:
type _1 = Expect<Equal<Trim<"str">, "str">>;
type _2 = Expect<Equal<Trim<" str ">, "str">>;
type _3 = Expect<Equal<Trim<" \n\t foo ">, "foo">>;
拆解:联合模式 + 递归——每轮剥掉一侧一个空白字符,剥到没有空白匹配为止。
3.3 第 110 题 · Replace(medium)
// 题目:Replace<"foo bar", "foo", "hi"> → "hi bar"(只换第一处)
// Replace<"bar", "foo", "hi"> → "bar"(找不到原样返回)
type Replace<S extends string, From extends string, To extends string> =
From extends ""
? S // 边界:From 是空串 → 不做事
: S extends `${infer Head}${From}${infer Tail}`
? `${Head}${To}${Tail}`
: S;
// 用例:
type _1 = Expect<Equal<Replace<"types are fun!", "fun", "awesome">, "types are fun">>;
// ⚠ 这条用例的结果——"fun" 在句尾但用例没替换它?!
// 看用例期望:期望输出是 "types are fun"(没变)
// 因为原句是 "types are fun!",From = "fun" 匹配的是 "fun!" 中的
// "fun" 吗?——匹配了,但用例期望不变?
// 真实用例:Replace<"foobarbar", "bar", "foo"> → "foobarbar"?
// 不——实际用例是 Replace<"foobar", "bar", "foo"> → "foofoo"
// ⭐ 教学:读用例!用例是唯一真相来源,别脑补题目
核心机制:模式串混排 字面量 + infer——${From} 作为模式的一部分被"定位",Head/Tail 捕获前后两段。
3.4 第 116 题 · ReplaceAll(medium · 今日主菜)
// 题目:ReplaceAll<"foo foo", "foo", "hi"> → "hi hi"(换掉每一处)
// ===== 先自己写 15 分钟 =====
// 思路:一次 Replace 后,对"剩余部分"(Tail)递归
type ReplaceAll<S extends string, From extends string, To extends string> =
From extends ""
? S
: S extends `${infer Head}${From}${infer Tail}`
? `${Head}${To}${ReplaceAll<Tail, From, To>}`
: S;
// ⭐ 关键:只对 Tail 递归,To 不参与下一轮匹配
// —— 防止 To 里含 From 时的死循环(如 ReplaceAll<"aa","a","aa">)
// 用例:
type _1 = Expect<Equal<ReplaceAll<"foobar", "bar", "foo">, "foofoo">>;
type _2 = Expect<Equal<ReplaceAll<"foobarbar", "bar", "foo">, "foofoofoo">>;
type _3 = Expect<Equal<ReplaceAll<"foobarfoobar", "ob", "ob">, "foobarfoobar">>;
// ↑ 用例 3:To === From —— 递归只对 Tail 生效,所以不死循环、结果不变 ✅
本题的教学核心:递归的"范围控制"——替换结果 To 不进入下一轮匹配,只有未处理的 Tail 继续。这是字符串递归里最重要的安全设计。
3.5 加餐题三连(自命题)
// ===== 加餐 1:StringToUnion =====
// "abc" → "a" | "b" | "c"
type StringToUnion<S extends string> =
S extends `${infer F}${infer R}` ? F | StringToUnion<R> : never;
// 联合的 | 在递归中累积,空串时 never 被吸收
// ===== 加餐 2:Length(字符串版元组 Length)=====
// "hello" → 5
type StrLength<S extends string> =
S extends `${string}${infer R}` ? [...StrCount<R>] extends { length: infer L } ? L : 0 : 0;
// 复杂了?换个思路——先转元组再取 length:
type StrToTuple<S extends string> =
S extends `${infer F}${infer R}` ? [F, ...StrToTuple<R>] : [];
type StrLength2<S extends string> = StrToTuple<S>["length"];
type L = StrLength2<"hello">; // 5 ⭐
// 技巧:字符串题不会做 → 转成元组题(昨天的五虎将直接用)
// ===== 加餐 3:DropChar =====
// DropChar<"a-b-c", "-"> → "abc"
type DropChar<S extends string, C extends string> =
S extends `${infer Head}${C}${infer Tail}` ? DropChar<`${Head}${Tail}`, C> : S;
// ReplaceAll 的变体:匹配到 C 就"删掉"(Head+Tail 直接拼接)
// ⚠ 注意这里对拼接后的整体递归(删除场景无死循环风险)
四、组合技:事件名解析器
把今天的全部知识组装成一个工业级工具:
/**
* 大屏事件总线的事件名协议:"域:动作:目标"
* 全部类型层完成:解析、校验、载荷推导
*/
// ===== 第 1 层:合法值的定义 =====
type KnownDomain = "device" | "alert" | "hub";
type KnownAction = "get" | "set" | "on";
// ===== 第 2 层:格式校验(模式 + 联合白名单)=====
type IsValidEvent<E extends string> =
E extends `${KnownDomain}:${KnownAction}:${string}` ? true : false;
type A = IsValidEvent<"device:set:cnc">; // true
type B = IsValidEvent<"foo:set:cnc">; // false(域不在白名单)
type C = IsValidEvent<"device:fly:cnc">; // false(动作不在白名单)
// ===== 第 3 层:分段解析(多段捕获)=====
type ParseEvent<E extends string> =
E extends `${infer D}:${infer A}:${infer T}`
? { domain: D; action: A; target: T }
: never;
type P = ParseEvent<"alert:on:cnc-001">;
// { domain: "alert"; action: "on"; target: "cnc-001" }
// ===== 第 4 层:载荷类型推导(解析 + 条件分发)=====
type EventPayload<E extends string> =
E extends `device:${infer A}:${string}`
? A extends "get" ? { kind: "snapshot" } : { kind: "delta" }
: E extends `alert:${infer A}:${string}`
? A extends "on" ? { level: 1 | 2 | 3 } : { id: string }
: { ts: number };
type L1 = EventPayload<"device:get:cnc">; // { kind: "snapshot" }
type L2 = EventPayload<"alert:on:cnc-001">; // { level: 1 | 2 | 3 }
// ===== 组装:类型安全的事件订阅(运行时函数的编译期门卫)=====
function subscribe<E extends string>(
event: IsValidEvent<E> extends true ? E : never,
handler: (payload: EventPayload<E>) => void
): void {}
subscribe("device:get:cnc", p => console.log(p.kind)); // ✅ p 类型精确
subscribe("foo:get:cnc", p => {}); // ❌ 编译期拦截
// 事件名字符串 + 载荷类型 + 白名单校验 —— 一行业务代码没写,规则全部编译期锁定
五、工业实战场景
5.1 场景一:WebSocket 消息路由的类型约束
/**
* 工业物联网的 WS 消息格式:"channel/deviceId/command"
* 类型层完成"频道白名单 + 参数格式"双重校验
*/
type Channel = "telemetry" | "command" | "ack";
type Command = "start" | "stop" | "report";
type WsTopic =
| `telemetry/${string}` // 遥测:任意设备
| `command/${string}/${Command}` // 指令:设备 + 合法指令
| "ack"; // 确认:固定
function wsSend(topic: WsTopic): void {}
wsSend("telemetry/CNC-001"); // ✅
wsSend("command/CNC-001/start"); // ✅
wsSend("command/CNC-001/fly"); // ❌ 指令不在白名单
wsSend("telemetry"); // ❌ 缺设备号
5.2 场景二:主题配置的 CSS 变量名生成
/**
* 大屏主题:TS 配置对象 → CSS 变量命名的自动推导
*/
interface ThemeConfig {
color: { primary: string; danger: string };
fontSize: { sm: number; lg: number };
}
/** 键路径转 CSS 变量名:"color.primary" → "--color-primary" */
type ToCssVarName<Path extends string> =
Path extends `${infer A}.${infer B}` ? `${A}-${B}` : Path;
type CssVars = {
[K in keyof ThemeConfig as `--${ToCssVarName<string & K>}`]: ThemeConfig[K];
};
// { --color: { primary... }; --fontSize: { sm... } } —— 还差一层!
/** 完全体:两层展平(递归版留给读者——Day 19 的练习) */
type FlattenVars<T, Prefix extends string = ""> = {
[K in keyof T as `--${Prefix}${string & K}`]:
T[K] extends object ? FlattenVars<T[K], `${Prefix}${string & K}-`> : T[K];
};
type Vars = FlattenVars<ThemeConfig>;
// {
// "--color-primary": string;
// "--color-danger": string;
// "--fontSize-sm": number;
// "--fontSize-lg": number;
// } ⭐ 配置对象到 CSS 变量的完整类型映射
5.3 场景三:蛇形转驼峰(接口层,第 2 周伏笔的完全体)
/**
* 后端蛇形 → 前端驼峰:今天你能讲清每一行了
*/
type SnakeToCamel<S extends string> =
S extends `${infer Head}_${infer Tail}`
? `${Head}${Capitalize<SnakeToCamel<Tail>>}`
: S;
// 执行模拟("work_temp_max"):
// 第 1 轮:Head="work",Tail="temp_max" → "work" + Cap<"temp_max"递归...>
// 第 2 轮(Tail="temp_max"):Head="temp",Tail="max" → "temp" + Cap<"max">
// 第 3 轮(Tail="max"):无下划线 → "max"
// 组装:Capitalize<"max"> = "Max" → "tempMax"
// Capitalize<"tempMax"> = "TempMax"?⚠ 不对——
// 仔细看:第 1 轮的 Capitalize 作用在递归结果 "tempMax" 上 → "TempMax"
// 结果:"work" + "TempMax" = "workTempMax" ✅(首个下划线后的整段首字母大写)
type R = SnakeToCamel<"work_temp_max">; // "workTempMax"
六、类比记忆:正则引擎 vs 蚕吃桑叶
模板字面量 + infer = 类型正则引擎
┌───────────────────────────────────────┐
│ 正则概念 类型层对应物 │
│ ───────────────────────────── │
│ 字面量 abc ${abc}(字面匹配) │
│ 捕获组 (...) ${infer X}(捕获) │
│ 交替 a|b ${"a"|"b"}(联合) │
│ 量词 * / + 递归(啃完为止) │
│ 锚点 ^ $ StartsWith/EndsWith │
│ 替换 sub() ReplaceAll │
└───────────────────────────────────────┘
递归啃字符串 = 蚕吃桑叶
┌───────────────────────────────────────┐
│ 蚕(递归函数)一口一口啃 │
│ 每口啃一个字符(缩小规模) │
│ 啃到叶脉(不匹配的模式)→ 停下吐丝 │
│ 吐的丝(捕获的 infer)织成茧(结果) │
└───────────────────────────────────────┘
七、常见坑点与最佳实践
坑点 1:${infer F} 的非贪婪误判
// 以为 F 能匹配多个字符:
type X = "abc" extends `${infer A}${infer B}` ? [A, B] : never;
// [A, B] = ["a", "bc"] —— A 只有一个字符!
// 需要精确分段必须给显式边界(字面量分隔符)
// 没有分隔符的"任意切分"在类型层不存在
坑点 2:ReplaceAll 的死循环风险
// 危险写法:对替换后的整体递归
type Bad<S, From extends string, To extends string> =
S extends `${infer H}${From}${infer T}` ? Bad<`${H}${To}${T}`, From, To> : S;
// ReplaceAll<"aa", "a", "aa">:
// "a"+aa+"a" = "aaaa" → 又匹配 → "aaaaaa" → …… 💥 爆栈
// ✅ 正解:只对 Tail 递归(To 不参与下轮匹配)
// 例外:DropChar(删除场景,To = 空)可以对整体递归
坑点 3:From extends “” 的边界漏判
// Replace 系列题的经典边界:
type ReplaceNoCheck<S, From extends string, To extends string> =
S extends `${infer H}${From}${infer T}` ? `${H}${To}${T}` : S;
// ReplaceNoCheck<"abc", "", "x">
// "" 在模式中匹配空串 → H="a",From="",T="bc" → "axbc" ⚠
// 且每轮都匹配空串 → 死循环!
// ✅ 题目用例几乎必测空 From——开头先判 From extends "" ? S
坑点 4:字符串工具只对字面量生效
type A = Uppercase<"abc">; // "ABC" ✅
type B = Uppercase<string>; // string ⚠ 变换失效
// 泛型签名务必加 extends string 约束,保持字面量细腻度:
type Fn<S extends string> = Uppercase<S>; // ✅ S 保持字面量传入
type BadFn<S> = Uppercase<S>; // ❌ 编译错误 / 失效
坑点 5:读题不读用例
// Replace 题的真实用例里藏着"陷阱用例"(句尾 From 不匹配的场景)
// 脑补题意 = 答错——用例是唯一真相
// 养成习惯:先抄下全部用例,逐条注释预期,再写实现
最佳实践清单
字符串题卡壳 → 转元组题(StrToTuple + 昨天的五虎将)
Replace 系列必做两个检查:空 From 边界 + 死循环风险(To 参与?)
联合模式处理"多形状":Trim 的两侧空白、TrimLeft 的多种空白字符
用例先行:抄用例 → 注释预期 → 写实现 → 全绿
写完做"执行模拟":拿一个输入逐轮走一遍递归
八、自测挑战
Q1:元组的 [infer F, ...infer R] 和字符串的 `${infer F}${infer R}` 在语义上如何对应?后者有什么"非贪婪"特性?
Q2:联合模式 S extends ` ${infer R}` | `${infer R} ` 匹配的优先级是什么?" hi " 传入会得到什么?
Q3:ReplaceAll 为什么只对 Tail 递归?什么场景下这个设计避免了爆栈?
Q4:Replace 为什么必须先判 From extends ""?不判会发生什么?
Q5:手写 DropFirst<S> 和 StrLength<S>(不许翻第三节)。
Q6:`${infer A}.${infer B}` 对 "a.b.c" 的捕获结果是什么?为什么 B 吸收了剩余?
Q7:SnakeToCamel<"work_temp_max"> 的三轮递归各捕获什么?Capitalize 分别作用在什么上?
Q8:设计一个 IsValidEvent<E>,要求 "device"/"alert" 域 + "get"/"set" 动作 + 任意目标的白名单校验。
Q9:字符串题卡壳时的"逃生通道"是什么?
Q10:正则的"捕获组/交替/量词"分别对应类型层的什么机制?
九、总结与知识图谱
字符串体操(Day 18)
│
├── 核心心法
│ ├── 字符串 ≈ 可啃的元组(同构性)
│ ├── ${infer F} 非贪婪(只吃一个字符)
│ └── 显式边界才能"贪婪"(字面量分隔符)
│
├── 模式匹配进阶
│ ├── 联合模式:` A${R}` | `${R}A `(或匹配)
│ ├── 多段捕获:${infer A}:${infer B}:${infer C}
│ └── 字面量 + infer 混排(类型正则)
│
├── 题目战绩
│ ├── TrimLeft(106):联合空白模式 + 递归
│ ├── Trim(108):两侧联合模式
│ ├── Replace(110):模式定位 + 头尾捕获
│ ├── ReplaceAll(116):只对 Tail 递归(防死循环)
│ └── 加餐:StringToUnion / StrLength / DropChar
│
├── 组合技
│ └── 事件名解析器:校验 + 解析 + 载荷分发三层组装
│
└── 工业落地
├── WsTopic:消息路由白名单
├── FlattenVars:配置 → CSS 变量名
└── SnakeToCamel:接口层命名转换
一句话总结:模板字面量是类型层的正则引擎——字面量做匹配、infer 做捕获、联合做交替、递归做量词;唯一的心法是"字符串就是可以啃的元组",啃不动就转元组。
延伸阅读
下一步
明天(Day 19)进入对象体操:Chainable Options(链式累积)、Flip(键值互换)、DeepReadonly(函数边界)——以及本周最大的工业落地 Get<T, Path> 嵌套路径取值。
字符串体操的尽头是"类型正则"——写多了你会发现自己在用正则思维写类型。
每天花 2 小时,28 天通关 TypeScript 深入。第 3 周第 4 天,Trim/Replace 全家到手!