JS 类型校验实践
最近写的一个项目对导出的 json 数据格式要求较为严格,因此在测试数据格式上花了很多时间。此处对前后使用过的数据类型校验工具进行记录。
另外,虽然题目中说是 JS 中的类型校验,但是因个人开发习惯,以下涉及代码的部分均使用 TS
探究的产生
在校验数据类型的需求产生之后,有一个很大的问题摆在我面前:原先我已经定义了一套 Type 用于约束数据类型,此时又要各自在前后端中对数据进行类型校验,那最坏可能要同时维护三套类型定义,分别是 TS 中定义的类型、用于前端校验的类型定义、用于后端校验的类型定义,这显然增加了维护成本。正因如此,我寄望于能仅使用一套类型定义,来同时完成以上三种对类型定义的需求。
提前声明,本人因为一些原因,在项目中并没有实际解决以上的问题,但是确实找到了理论上可行(但是不适用于本项目)的方法。因为在这个问题上反复折腾了很久,所以现在先把这个问题抛出来,方便后文引用,也欢迎有相关实践经验的朋友找我交流更好的方案。
midwayjs 自带的校验
由于项目后端采用 midwayjs 开发,而 midwayjs 自带参数校验功能,其官方文档详见:https://midwayjs.org/docs/extensions/validate 。其 validate 主要的形式就是以 class 形式定义属性,再用装饰器对属性的类型进行约束。
个人的使用感受是定义较为繁琐,且前端似乎无法直接引用该 class 定义的类型。该方案更适用于 midwayjs 后端参数的校验。
校验定义的 example
- 简单的校验
typescriptimport { Rule, RuleType } from '@midwayjs/validate'; export class DemoDTO { @Rule(RuleType.string().required()) name: string; @Rule(RuleType.string()) address: string; } async update(@Body body: DemoDTO) { return body; }
可以看出,对类型校验 rule 的定义,主要表现为 RuleType 上语义化地叠上的各种 buff。
- 复杂对象的校验
typescriptimport { Rule, RuleType, getSchema } from "@midwayjs/validate"; export class SchoolDTO { @Rule(RuleType.string().required()) name: string; @Rule(RuleType.string()) address: string; } export class UserDTO { // 复杂对象 @Rule(getSchema(SchoolDTO).required()) school: SchoolDTO; // 对象数组 @Rule(RuleType.array().items(getSchema(SchoolDTO)).required()) schoolList: SchoolDTO[]; }
这里对复杂对象的校验主要基于 getSchema 实现,直接引用相关类型入参,剩下的就跟简单类型一样叠 buff 就好。
- 复用类型校验
typescriptimport { Rule, RuleType, PickDto } from "@midwayjs/validate"; export class UserDTO { @Rule(RuleType.number().required()) id: number; @Rule(RuleType.string().required()) firstName: string; @Rule(RuleType.string().max(10)) lastName: string; @Rule(RuleType.number().max(60)) age: number; } // 继承出一个新的 DTO export class SimpleUserDTO extends PickDto(UserDTO, [ "firstName", "lastName", ]) {}
此处是借用了 class 的继承特性。但是由于多继承对 js 来说很麻烦,所以一次只能继承一个其他类型的属性定义。这也是本人使用过程中一些不好的体验感的来源
另外,可以看出它也借鉴了 TS 里的一些内置定义,如 Pick 和 Omit,感兴趣的朋友可以自行查阅官网文档。
校验的调用方式
- 通过对函数入参指定参数的 type,隐式进行校验
typescriptimport { Rule, RuleType } from '@midwayjs/validate'; export class DemoDTO { @Rule(RuleType.string().required()) name: string; @Rule(RuleType.string()) address: string; } async update(@Body body: DemoDTO) { return body; }
此处,当调用 update 方法时,后端就会先对传入的 body 进行类型校验。若校验不通过,则会直接报错。
- 通过调用 validate 函数,显式进行校验
typescriptimport { ValidateService } from "@midwayjs/validate"; export class UserService { @Inject() validateService: ValidateService; async inovke() { // ... const result = this.validateService.validate(UserDTO, { name: "harry", nickName: "harry", }); // 失败返回 result.error // 成功返回 result.value } }
使用 zod
zod 是我在思考这个问题时曾经觉得希望最大的一个方案。一方面 zod 实际上在 midwayjs 官网中对类型校验的部分有提到过,甚至有很多相关的 example;另一方面,zod 提供了 z.infer 这一工具,它可以将 zod 定义的校验类型转化为 TS 类型,所以理论上它应该是 TS 的 type 类型定义、前端的类型校验类型定义、后端的类型校验规则定义都可以胜任的。
不同于 midwayjs 自带的校验,zod 不采用 class 定义,而是将类型约束返回给一个自定义的变量;而相同的是,在定义简单类型时都采用了叠 buff 的形式。相对而言比较贴合本人的编程习惯,在进行类型复用时也不会像第一个方法一样复杂,是本人觉得比较友好方便的一种实践。
zod 的官方文档地址:https://zod.dev/
重要的 tips!
在文档的 https://zod.dev/?id=requirements ,提出了使用 zod 的使用条件。那就是:
-
TypeScript 4.5+
-
tsconfig.json中必须设置compilerOptions.strict为 true
在本人所有类型都写好之后,才注意到这个第二点……当时调用 z.infer 导出 type 的时候出现了爆红,另外我这边的类型提示莫名默认将所有属性变成了 optional (而其他人拉下来的时候却是正常的必选,至今不知道问题出在哪)。而且当项目里配置改成 strict 之后,项目很多其他地方全爆红了,不得已搁置了这个方法。各位使用前一定要检查自己的项目符不符合要求。
校验定义的 example
- 常用类型的校验
typescriptimport { z } from "zod"; const User = z.object({ username: z.string().len(1), });
可以看出 zod 的定义形式很灵活,写起来的形式也很符合平时函数式编程的直觉。
- Record 类型的支持
zod 直接提供了 z.record 这个 api,而本文提到的其他两个库对此似乎并没有比较好的支持(或者说文档说明并不完善
typescriptconst NumberCache = z.record(z.number()); type NumberCache = z.infer<typeof NumberCache>; // => { [k: string]: number }
- 支持
pick/omit/partial
校验的调用方式
typescriptimport { z } from "zod"; // creating a schema for strings const mySchema = z.string(); // parsing mySchema.parse("tuna"); // => "tuna" mySchema.parse(12); // => throws ZodError // "safe" parsing (doesn't throw error if validation fails) mySchema.safeParse("tuna"); // => { success: true; data: "tuna" } mySchema.safeParse(12); // => { success: false; error: ZodError }
如上,可以调用 parse 和 safeParse 对数据进行校验。不同的是,parse 校验不通过会直接抛出错误,而 safeParse 不会直接抛错,而是通过 success 字段标记校验状态。
实现校验规则与 TS 类型一体化的实践
相关详细文档见:https://zod.dev/?id=recursive-types
typescriptconst baseCategorySchema = z.object({ name: z.string(), }); type Category = z.infer<typeof baseCategorySchema> & { subcategories: Category[]; }; const categorySchema: z.ZodType<Category> = baseCategorySchema.extend({ subcategories: z.lazy(() => categorySchema.array()), }); categorySchema.parse({ name: "People", subcategories: [ { name: "Politicians", subcategories: [ { name: "Presidents", subcategories: [], }, ], }, ], }); // passes
以上示例展示了 zod 中 TS 类型与 zod 规则之间的转化方法。TS 类型可以通过 z.ZodType 转化成 zod 规则,zod 规则可以通过 z.infer 转化成 TS 类型。由此可实现 TS 类型跟校验规则的统一,即只需维护一套 zod 规则,再由 z.infer 导出为 TS 类型使用。并且 zod 跟框架无关,前后端均可使用。
此外,这部分也展示了类型嵌套的处理方法,此处不多赘述了。
坑点
-
如开头 tips 所述,可能会因为项目不符合
zod的使用条件,碰到项目爆红的情况。 -
对于非空数组叠了个
nonEmpty的 buff,但是这很有可能跟定义的 TS 类型冲突
typescriptconst nonEmptyStrings = z.string().array().nonempty(); // the inferred type is now // [string, ...string[]]
如上所示,一般来说这种数组类型也不会特意定义成 [string, ...string[]] 的形式,大概率也就是 string[],但是这两个类型在 zod 里是不兼容的。这个问题本人还排查了好一会,最后才发现竟然是这里的问题……
- 要求必填但是内容可能为空的字符串的情况不好解决
默认 z.string() 就是要求该字段必填且类型为 string,而这样是不能通过空字符串的校验的。但是 zod 的 github issue 里有人提出过这个问题,并且给了一些解决办法,可以去查查
async-validator
这个库也是 antd 中对 form 进行校验时使用的底层库。跟 zod 同样跨平台,不过使用起来比较繁琐,定义相对较绕,心智负担较大。
严格来说
antd应该是间接引用。antd直接使用的是rc-field-form(https://github.com/ant-design/ant-design/blob/master/components/form/Form.tsx#L2C52-L2C53) ,而rc-field-form实现 validate 又是基于async-validator(https://github.com/react-component/field-form/blob/master/src/utils/validateUtil.ts#L1)
async-validator 官方文档见:https://github.com/yiminghe/async-validator
校验定义的 example
- 简单类型的校验
typescriptimport { Rules } from "async-validator"; const descriptor: Rules = { name: { type: "string", required: true, validator: (rule, value) => value === "muji", }, age: { type: "number", asyncValidator: (rule, value) => { return new Promise((resolve, reject) => { if (value < 18) { reject("too young"); // reject with error message } else { resolve(); } }); }, }, };
如上,只是鉴定类型的话只需要指定 type;而自定义校验的话可以用 validator 或 asyncValidator
- 对象类型的校验
typescriptconst descriptor = { urls: { type: "array", required: true, defaultField: { type: "string" }, }, }; // => 相当于校验: // type Type = { // url: string; // }[]
当然,也可以通过枚举的方式一一指定 array 中的每个元素。但是只适用于有限长度的 array:
typescriptconst descriptor = { roles: { type: "array", required: true, len: 3, fields: { 0: { type: "string", required: true }, 1: { type: "string", required: true }, 2: { type: "string", required: true }, }, }, }; // => 相当于校验: // type Type = [string, string, string];
- 复杂类型嵌套
比如对于以下类型:
typescripttype data = { students: { student1: string; student2: string; }; };
并且已有对 students 进行约束的 descriptor:
typescriptconst students = ["student1", "student2"]; const studentsDescriptor: Rules = students.reduce( (acc, student) => ({ ...acc, [student]: { type: "string", }, }), {} as Rules );
那在引用 studentDescriptor 时,需要显式指定 students 的类型是 object:
typescriptconst descriptor: Rules = { students: { type: "object", required: true, defaultFields: studentsDescriptor, }, };
若直接写 students: studentsDescriptor,则校验该字段时会直接认为 students 字段类型为 string。
校验的调用方式
typescriptimport Schema from 'async-validator'; const descriptor = { name: { type: 'string', required: true, pattern: /^[a-z]+$/, transform(value) { return value.trim(); }, }, }; const validator = new Schema(descriptor); const source = { name: ' user ' }; // 校验调用方式 1 validator.validate(source) .then((data) => assert.equal(data.name, 'user')); // 校验调用方式 2 validator.validate(source, (errors, data) => { assert.equal(data.name, 'user')); });
总结
midwayjs 自带的校验适用于 midwayjs 后端,validate 借助 class 的装饰器实现,灵活度相对较低。如果 react 前端要引入该 class 作为 type 需要额外进行配置(如 https://zhuanlan.zhihu.com/p/335290638) ,也是一种解决开头提出的问题的办法。
zod 不依托于框架,前后端均可使用,TS 友好,定义的类型可以跟校验规则互相转化。规则定义简便且语义化,相对来说更适合用于解决本人在文章开头提出的问题。但是更适合新项目(ts 4.5+ 且编译设置为严格模式),(不符合要求的)旧项目要强行使用 zod 可能需要花费不少修改成本。
async-validator 也不依托于框架,前后端均可使用,无法作为 TS 的 type 使用。定义较繁琐。