Yuzhe's Blog

yuzhes

百分比解析器

百分比解析器

题目链接

题目

实现 PercentageParser<T extends string>
根据正则 /^(\+|\-)?(\d*)?(\%)?$/ 匹配 T,并提取三个捕获组。

结构为:[符号, 数字, 单位]
未捕获的组默认为空字符串 ''

type PString1 = ""
type PString2 = "+85%"
type PString3 = "-85%"
type PString4 = "85%"
type PString5 = "85"

type R1 = PercentageParser<PString1> // ['', '', '']
type R2 = PercentageParser<PString2> // ['+', '85', '%']
type R3 = PercentageParser<PString3> // ['-', '85', '%']
type R4 = PercentageParser<PString4> // ['', '85', '%']
type R5 = PercentageParser<PString5> // ['', '85', '']

解法

最简洁的写法是一条嵌套的条件类型链:

type PercentageParser<T extends string> =
  T extends `${'+' | '-'}${infer Rest}`
    ? T extends `${infer Sign}${Rest}`
      ? Rest extends `${infer Num}%`
        ? [Sign, Num, '%']
        : [Sign, Rest, '']
      : ['', '', '']
    : T extends `${infer Num}%`
      ? ['', Num, '%']
      : ['', T, '']

逐步拆解

第一步 — 检测可选符号

T extends `${'+' | '-'}${infer Rest}`

如果 T+- 开头,就匹配成功,并将剩余部分捕获为 Rest
'+' | '-' 是联合类型,TypeScript 会对它分发——相当于 OR 逻辑。

第二步 — 提取符号字符

T extends `${infer Sign}${Rest}`

已知 T 以符号开头,而 Rest 是已知的具体类型,TypeScript 可以将 Sign 精确锁定为第一个字符。

第三步 — 检测可选 % 后缀

Rest extends `${infer Num}%`

如果剩余字符串以 % 结尾,提取数字部分 Num,单位为 '%';否则单位为 ''

第四步 — 处理无符号的情况

T extends `${infer Num}%`
  ? ['', Num, '%']
  : ['', T, '']

没有符号时,检查 T 是否以 % 结尾。是则返回 ['', Num, '%'];否则整个字符串都是数字部分,单位为空。

执行轨迹示例

PercentageParser<'+85%'>
→ 匹配 '+' | '-' 前缀 → Rest = '85%'
→ Sign = '+'
'85%' extends `${infer Num}%` → Num = '85'
→ ['+', '85', '%'] ✓

PercentageParser<'85%'>
→ 不匹配 '+' | '-' 前缀
'85%' extends `${infer Num}%` → Num = '85'
→ ['', '85', '%'] ✓

PercentageParser<''>
→ 不匹配 '+' | '-' 前缀
'' 不匹配 `${infer Num}%`
→ ['', '', ''] ✓

深入理解

为什么模板字面量里的联合类型可以当 OR 用

TypeScript 在条件类型中会分发联合类型。在模板字面量里写 '+' | '-' 等价于:

“T 是否以 + 开头,或者- 开头?”

两个分支解析出的 Rest 类型相同(字符串的尾部),所以联合自然合并。

两步提取符号的技巧

你可能会问:既然已经匹配了 T extends \{'+' | '-'}\{infer Rest}“,为什么不能直接拿到符号?

原因在于 '+' | '-' 是一个联合类型,而非 infer 捕获——无法直接命名它。解决方式是再做一次匹配:

T extends `${infer Sign}${Rest}`

这里 Rest 已经是具体已知的类型,TypeScript 就能把 Sign 精确锁定为唯一的前缀字符。这是类型层面字符串解析的常见惯用写法。

辅助类型的写法

社区里也有人把三个捕获组分别拆成独立的辅助类型:

type GetSign<T extends string> =
  T extends `${'+' | '-'}${string}` ? (T extends `${infer S}${string}` ? S : never) : ''

type GetUnit<T extends string> =
  T extends `${string}%` ? '%' : ''

type GetNumber<T extends string,
               S extends string = GetSign<T>,
               U extends string = GetUnit<T>> =
  T extends `${S}${infer N}${U}` ? N : ''

type PercentageParser<T extends string> =
  [GetSign<T>, GetNumber<T>, GetUnit<T>]

这种写法可读性更高(每个辅助类型只负责一件事),和正则 /^(\+|\-)?(\d*)?(\%)?$/ 的三个捕获组一一对应。

代价是:对 T 做了三次独立求值,在大规模项目中可能略微影响类型检查性能。

边界情况一览

输入期望结果说明
""['', '', '']空字符串,所有组默认 ''
"+"['+', '', '']只有符号,无数字无单位
"%"['', '', '%']只有单位
"+%"['+', '', '%']符号 + 单位,无数字
"-0%"['-', '0', '%']负零百分比

与其他字符串解析题的关系

题目技术要点
Trim (108)去除前缀/后缀字符
Replace (116)按子串分割
StartsWith (2688)模板字面量检测前缀
EndsWith (2693)模板字面量检测后缀
PercentageParser (1978)同时提取前缀 + 中间 + 后缀
Split (2822-hard)按分隔符递归分割

PercentageParser 是将前缀检测(StartsWith 风格)与后缀检测(EndsWith 风格)组合在单个类型表达式中的典型练习。

核心要点