第 17 期 - Using TypeScript's infer keyword and recursive types to build a schema builder
摘要
本文先介绍了构建模式构建库时遇到的问题,然后阐述了 TypeScript 中 infer 关键字和递归类型的强大功能,包括解析基本类型、对象、对象属性、数组属性、可选值、处理空白字符等方面的应用,最后提到了相关开源库 strema。
一、构建模式构建库的问题引入
在构建模式构建库时,希望能使用更简洁、更接近原生语法的方式来描述模式。例如,像 JavaScript 中的yup
库那样的语法,但又觉得其存在一些过于冗长的部分。像标记字段为必填、添加默认值、处理值列表等操作,希望有更紧凑的写法。同时,提出了一些理想的语法示例,但这些示例不符合 JavaScript 或 TypeScript 的语法。
二、TypeScript 的类型转换相关功能
(一)解析基本类型
- 条件类型与
extends
关键字- TypeScript 中的
extends
关键字可用于创建条件类型。例如,type Test<T> = T extends "yes"?0:1;
可以根据T
的值来确定类型。 - 通过
extends
关键字可以获取输入字符串所代表的基本类型,如type ParsePrimitive<T> = T extends "string"?string:T extends "number"?number:T extends "boolean"?boolean:never;
,不过这种写法的条件判断比较冗长。
- TypeScript 中的
(二)解析对象
- 判断对象字符串类型
- 可以使用模板字面量类型来判断一个字符串是否符合键值对象模式,如
type IsObjectString<T> = T extends
{${string}:${string}}?true:false;
。
- 可以使用模板字面量类型来判断一个字符串是否符合键值对象模式,如
infer
关键字的应用infer
关键字允许在模式匹配时创建变量。例如,type ParseObject<T> = T extends
{${infer K}:${infer V}}?{[key in K]:ParsePrimitive<V>}:never;
,这里的K
和V
只有在T
匹配模式时才会被创建。
(三)处理多个属性的对象
- 朴素但不可扩展的方法
- 对于有多个属性的对象,朴素的方法是为每个属性数量创建多个条件判断,如
type ParseObject<T> = T extends
{${infer K0}:${infer V0}}?{[key in K0]:ParsePrimitive<V0>}:T extends
{${infer K0}:${infer V0};${infer K1}:${infer V1}}?{[key in K0]:ParsePrimitive<V0>}&{[key in K1]:ParsePrimitive<V1>}:T extends
{${infer K0}:${infer V0};${infer K1}:${infer V1};${infer K2}:${infer K2}}?{[key in K0]:ParsePrimitive<V0>}&{[key in K1]:ParsePrimitive<V1>}&{[key in K2]:ParsePrimitive<V2>}:never;
,这种方法很糟糕且不可扩展。
- 对于有多个属性的对象,朴素的方法是为每个属性数量创建多个条件判断,如
- 分割与合并属性的方法
- 可以先将包含
N
个属性的对象字符串T
的内容分割成N
个包含一个属性的字符串,然后分别解析每个属性字符串。例如type ParseObject<T> = T extends
{${infer Content}}?MergeArrayOfObjects<ParseProperties<SplitProperties<Content>>> : never;
。 - 其中
SplitProperties
用于分割属性字符串,ParseProperties
用于解析分割后的属性字符串数组,MergeArrayOfObjects
用于合并对象数组。
- 可以先将包含
三、处理对象属性
- 初步的分割属性尝试
- 最初的
SplitProperties
在处理对象属性时会出现问题,因为其模式匹配是贪婪的,会错误地分割对象属性。例如type SplitProperties<T> = T extends
${infer A};${infer B}?[A,...SplitProperties<B>]:[T];
在处理a:{b:string;c:number};d:boolean
时会得到错误的结果。
- 最初的
- 改进的分割属性尝试及其问题
- 尝试通过先按对象分割再按
;
分割来改进,但这种方法在对象嵌套更深或对象为最后一个属性时容易出现问题。例如type SplitProperties<T> = T extends
${infer A}{${infer Content}};${infer B}?[
${A}{${Content}},...SplitProperties<B>]:T extends
${infer A};${infer B}?[A,...SplitProperties<B>]:[T];
在处理a:{b:{c:string};d:number};e:boolean
时会得到错误结果。
- 尝试通过先按对象分割再按
- 平衡括号解决对象属性分割问题
- 通过观察发现可以通过平衡括号来解决对象属性分割的问题。先判断字符串中
{
和}
的数量是否相等,如果不相等则合并当前元素和下一个元素,直到所有元素的括号都平衡。 - 在 JavaScript 中可以用递归函数实现,在 TypeScript 中可以用类似的递归类型实现,如
type BalanceBrackets<T extends string[]> = T extends [infer Curr extends string, infer Next extends string,...infer Rest extends string[]]?AreBracketsBalanced<Curr> extends true?[Curr,...BalanceBrackets<[Next,...Rest]>] : BalanceBrackets<[
${Curr};${Next},...Rest]> : T;
。 - 为了实现
AreBracketsBalanced
类型,需要先能够计算字符串中特定字符的数量。可以通过将字符串递归地转换为元组,然后过滤元组来计算特定字符的数量,如type StringToTuple<T extends string> = T extends
${infer Char}${infer Rest}?[Char,...StringToTuple<Rest>]:[];
和type FilterTuple<T extends any[], Include> = T extends [infer Item,...infer Rest]?Item extends Include?[Item,...FilterTuple<Rest, Include>]:FilterTuple<Rest, Include>:[];
,最终可以创建AreBracketsBalanced
类型来判断括号是否平衡。
- 通过观察发现可以通过平衡括号来解决对象属性分割的问题。先判断字符串中
四、解析属性
- 创建
KeyValue
类型- 因为属性无论是基本属性还是对象属性都以键和冒号开头,所以可以创建
KeyValue
类型,如type KeyValue<T extends string> = T extends
${infer K}:${infer V}?{key:K;value:ParseValue<V>}:never;
。
- 因为属性无论是基本属性还是对象属性都以键和冒号开头,所以可以创建
- 解析值的类型区分
- 在解析值时,可以区分对象属性和基本属性,如
type ParseValue<T> = T extends
{${string}}?ParseObject<T>:ParsePrimitive<T>;
。
- 在解析值时,可以区分对象属性和基本属性,如
- 创建
ParseProperty
类型- 基于
KeyValue
类型可以创建ParseProperty
类型,如type ParseProperty<T extends string> = KeyValue<T> extends {key: infer K extends string;value: infer V;}?{[key in K]:V}:never;
。
- 基于
五、处理数组属性
- 扩展
ParseValue
类型- 为了支持数组属性,可以扩展
ParseValue
类型来检查数组表示法。例如type ParseValue<T> = T extends
${infer Before}[]?ParseValue<Before>[]:T extends
{${string}}?ParseObject<T>:ParsePrimitive<T>;
。
- 为了支持数组属性,可以扩展
六、处理可选值
- 更新
KeyValue
类型- 希望能像在 TypeScript 中那样使用
?:
表示可选属性,所以更新KeyValue
类型来检查?:
的存在。例如type KeyValue<T extends string> = T extends
${infer K}:${infer V}?K extends
${infer KeyWithoutQuestionmark}??{key:KeyWithoutQuestionmark;value:ParseValue<V> | null}:{key:K;value:ParseValue<V>}:never;
,这里要注意避免在非可选对象属性包含可选属性时的匹配错误。
- 希望能像在 TypeScript 中那样使用
七、处理空白字符
- 去除空白字符的类型实现
- 因为 TypeScript 模板字面量对空白字符敏感,而用户编写模板时可能会有空白字符,所以需要去除空白字符。可以通过
infer
和递归实现去除空格、制表符、换行符等空白字符的类型,如type RemoveSpaces<T extends string> = T extends
${infer L} ${infer R}?RemoveSpaces<
${L}${R}>:T;
等,最终通过type RemoveWhitespace<T extends string> = RemoveSpaces<RemoveTabs<RemoveNewlines<T>>>;
将这些操作组合起来。
- 因为 TypeScript 模板字面量对空白字符敏感,而用户编写模板时可能会有空白字符,所以需要去除空白字符。可以通过
- 应用去除空白字符的操作
- 在顶级解析类型中包裹输入字符串来应用去除空白字符的操作,如
type Parse<T extends string> = ParseObject<RemoveWhitespace<T>>;
。
- 在顶级解析类型中包裹输入字符串来应用去除空白字符的操作,如
八、总结与开源库介绍
- 总结
- 文章展示了 TypeScript 中的
infer
关键字和递归类型的强大功能。
- 文章展示了 TypeScript 中的
- 开源库
strema
介绍- 提到了开源库
strema
,它是文章所实现内容的更成熟版本,实现了哈希映射、规则、默认值、类型测试、编译时自定义类型错误、运行时模板解析器和数据验证器等功能。最后鼓励读者查看源代码、进行修改或扩展。
- 提到了开源库
扩展阅读
Made by 捣鼓键盘的小麦 / © 2025 Front Talk 版权所有