【TS】day18-string-gymnastics

作者:mario 发布时间: 2026-08-31 阅读量:6 评论数:0

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 不匹配的场景)
// 脑补题意 = 答错——用例是唯一真相
// 养成习惯:先抄下全部用例,逐条注释预期,再写实现

最佳实践清单

  1. 字符串题卡壳 → 转元组题(StrToTuple + 昨天的五虎将)

  2. Replace 系列必做两个检查:空 From 边界 + 死循环风险(To 参与?)

  3. 联合模式处理"多形状":Trim 的两侧空白、TrimLeft 的多种空白字符

  4. 用例先行:抄用例 → 注释预期 → 写实现 → 全绿

  5. 写完做"执行模拟":拿一个输入逐轮走一遍递归


八、自测挑战

Q1:元组的 [infer F, ...infer R] 和字符串的 `${infer F}${infer R}` 在语义上如何对应?后者有什么"非贪婪"特性?

Q2:联合模式 S extends ` ${infer R}` | `${infer R} ` 匹配的优先级是什么?" hi " 传入会得到什么?

Q3ReplaceAll 为什么只对 Tail 递归?什么场景下这个设计避免了爆栈?

Q4Replace 为什么必须先判 From extends ""?不判会发生什么?

Q5:手写 DropFirst<S>StrLength<S>(不许翻第三节)。

Q6`${infer A}.${infer B}`"a.b.c" 的捕获结果是什么?为什么 B 吸收了剩余?

Q7SnakeToCamel<"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 做捕获、联合做交替、递归做量词;唯一的心法是"字符串就是可以啃的元组",啃不动就转元组。


延伸阅读

资源

说明

Template Literal Types

官方文档(含 infer 组合)

type-challenges 题号:106 / 108 / 110 / 116

今日题目

TS 4.1 release notes

模板串 + infer 的引入说明


下一步

明天(Day 19)进入对象体操:Chainable Options(链式累积)、Flip(键值互换)、DeepReadonly(函数边界)——以及本周最大的工业落地 Get<T, Path> 嵌套路径取值。


字符串体操的尽头是"类型正则"——写多了你会发现自己在用正则思维写类型。

每天花 2 小时,28 天通关 TypeScript 深入。第 3 周第 4 天,Trim/Replace 全家到手!


评论