第 17 期 - Using TypeScript's infer keyword and recursive types to build a schema builder
logoFRONTALK AI/11月9日 16:33/阅读原文

摘要

本文先介绍了构建模式构建库时遇到的问题,然后阐述了 TypeScript 中 infer 关键字和递归类型的强大功能,包括解析基本类型、对象、对象属性、数组属性、可选值、处理空白字符等方面的应用,最后提到了相关开源库 strema。

一、构建模式构建库的问题引入

在构建模式构建库时,希望能使用更简洁、更接近原生语法的方式来描述模式。例如,像 JavaScript 中的yup库那样的语法,但又觉得其存在一些过于冗长的部分。像标记字段为必填、添加默认值、处理值列表等操作,希望有更紧凑的写法。同时,提出了一些理想的语法示例,但这些示例不符合 JavaScript 或 TypeScript 的语法。

二、TypeScript 的类型转换相关功能

(一)解析基本类型

  1. 条件类型与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;,不过这种写法的条件判断比较冗长。

(二)解析对象

  1. 判断对象字符串类型
    • 可以使用模板字面量类型来判断一个字符串是否符合键值对象模式,如type IsObjectString<T> = T extends {${string}:${string}}?true:false;
  2. infer关键字的应用
    • infer关键字允许在模式匹配时创建变量。例如,type ParseObject<T> = T extends {${infer K}:${infer V}}?{[key in K]:ParsePrimitive<V>}:never;,这里的KV只有在T匹配模式时才会被创建。

(三)处理多个属性的对象

  1. 朴素但不可扩展的方法
    • 对于有多个属性的对象,朴素的方法是为每个属性数量创建多个条件判断,如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;,这种方法很糟糕且不可扩展。
  2. 分割与合并属性的方法
    • 可以先将包含N个属性的对象字符串T的内容分割成N个包含一个属性的字符串,然后分别解析每个属性字符串。例如type ParseObject<T> = T extends {${infer Content}}?MergeArrayOfObjects<ParseProperties<SplitProperties<Content>>> : never;
    • 其中SplitProperties用于分割属性字符串,ParseProperties用于解析分割后的属性字符串数组,MergeArrayOfObjects用于合并对象数组。

三、处理对象属性

  1. 初步的分割属性尝试
    • 最初的SplitProperties在处理对象属性时会出现问题,因为其模式匹配是贪婪的,会错误地分割对象属性。例如type SplitProperties<T> = T extends ${infer A};${infer B}?[A,...SplitProperties<B>]:[T];在处理a:{b:string;c:number};d:boolean时会得到错误的结果。
  2. 改进的分割属性尝试及其问题
    • 尝试通过先按对象分割再按;分割来改进,但这种方法在对象嵌套更深或对象为最后一个属性时容易出现问题。例如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时会得到错误结果。
  3. 平衡括号解决对象属性分割问题
    • 通过观察发现可以通过平衡括号来解决对象属性分割的问题。先判断字符串中{}的数量是否相等,如果不相等则合并当前元素和下一个元素,直到所有元素的括号都平衡。
    • 在 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类型来判断括号是否平衡。

四、解析属性

  1. 创建KeyValue类型
    • 因为属性无论是基本属性还是对象属性都以键和冒号开头,所以可以创建KeyValue类型,如type KeyValue<T extends string> = T extends ${infer K}:${infer V}?{key:K;value:ParseValue<V>}:never;
  2. 解析值的类型区分
    • 在解析值时,可以区分对象属性和基本属性,如type ParseValue<T> = T extends {${string}}?ParseObject<T>:ParsePrimitive<T>;
  3. 创建ParseProperty类型
    • 基于KeyValue类型可以创建ParseProperty类型,如type ParseProperty<T extends string> = KeyValue<T> extends {key: infer K extends string;value: infer V;}?{[key in K]:V}:never;

五、处理数组属性

  1. 扩展ParseValue类型
    • 为了支持数组属性,可以扩展ParseValue类型来检查数组表示法。例如type ParseValue<T> = T extends ${infer Before}[]?ParseValue<Before>[]:T extends {${string}}?ParseObject<T>:ParsePrimitive<T>;

六、处理可选值

  1. 更新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;,这里要注意避免在非可选对象属性包含可选属性时的匹配错误。

七、处理空白字符

  1. 去除空白字符的类型实现
    • 因为 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>>>;将这些操作组合起来。
  2. 应用去除空白字符的操作
    • 在顶级解析类型中包裹输入字符串来应用去除空白字符的操作,如type Parse<T extends string> = ParseObject<RemoveWhitespace<T>>;

八、总结与开源库介绍

  1. 总结
    • 文章展示了 TypeScript 中的infer关键字和递归类型的强大功能。
  2. 开源库strema介绍
    • 提到了开源库strema,它是文章所实现内容的更成熟版本,实现了哈希映射、规则、默认值、类型测试、编译时自定义类型错误、运行时模板解析器和数据验证器等功能。最后鼓励读者查看源代码、进行修改或扩展。
 

扩展阅读

Made by 捣鼓键盘的小麦 / © 2025 Front Talk 版权所有