半年前写的博客,在掘金进行一个补档。当时写的一个项目对导出的 json 数据格式要求较为严格,因此在测试数据格式上花了很多时间。此处对前后使用过的数据类型校验工具进行记录。
另外,虽然题目中说是 JS 中的类型校验,但是因个人开发习惯,以下涉及代码的部分均使用 TS
探究的产生
在校验数据类型的需求产生之后,有一个很大的问题摆在我面前:原先我已经定义了一套 Type 用于约束数据类型,此时又要各自在前后端中对数据进行类型校验,那最坏可能要同时维护三套类型定义,分别是 TS 中定义的类型、用于前端校验的类型定义、用于后端校验的类型定义,这显然增加了维护成本。正因如此,我寄望于能仅使用一套类型定义,来同时完成以上三种对类型定义的需求。
提前声明,本人因为一些原因,在项目中并没有实际解决以上的问题,但是确实找到了理论上可行(但是不适用于本项目)的方法。因为在这个问题上反复折腾了很久,所以现在先把这个问题抛出来,方便后文引用,也欢迎有相关实践经验的朋友勘误 & 找我交流更好的方案。
midwayjs 自带的校验
由于项目后端采用 midwayjs 开发,而 midwayjs 自带参数校验功能,其官方文档详见:midwayjs.org/docs/extens... 。其 validate 主要的形式就是以 class 形式定义属性,再用装饰器对属性的类型进行约束。
个人的使用感受是定义较为繁琐,且前端似乎无法直接引用该 class 定义的类型。该方案更适用于 midwayjs 后端参数的校验。
校验定义的 example
- 简单的校验
typescript
import { 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。
- 复杂对象的校验
typescript
import { 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 就好。
- 复用类型校验
typescript
import { 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,隐式进行校验
typescript
import { 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 函数,显式进行校验
typescript
import { 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 的官方文档地址:zod.dev/
重要的 tips!
在文档的 zod.dev/?id=require... ,提出了使用 zod 的使用条件。那就是:
-
TypeScript 4.5+
-
tsconfig.json
中必须设置compilerOptions.strict
为 true
在本人所有类型都写好之后,才注意到这个第二点......当时调用 z.infer
导出 type 的时候出现了爆红,另外我这边的类型提示莫名默认将所有属性变成了 optional
(而其他人拉下来的时候却是正常的必选,至今不知道问题出在哪)。而且当项目里配置改成 strict
之后,项目很多其他地方全爆红了,不得已搁置了这个方法。各位使用前一定要检查自己的项目符不符合要求。
校验定义的 example
- 常用类型的校验
typescript
import { z } from "zod";
const User = z.object({
username: z.string().len(1),
});
可以看出 zod
的定义形式很灵活,写起来的形式也很符合平时函数式编程的直觉。
- Record 类型的支持
zod
直接提供了 z.record
这个 api,而本文提到的其他两个库对此似乎并没有比较好的支持(或者说文档说明并不完善
typescript
const NumberCache = z.record(z.number());
type NumberCache = z.infer<typeof NumberCache>;
// => { [k: string]: number }
- 支持
pick
/omit
/partial
校验的调用方式
typescript
import { 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 类型一体化的实践
相关详细文档见:zod.dev/?id=recursi...
typescript
const 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 类型冲突
typescript
const 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
(github.com/ant-design/... ,而rc-field-form
实现 validate 又是基于async-validator
(github.com/react-compo...
async-validator
官方文档见:github.com/yiminghe/as...
校验定义的 example
- 简单类型的校验
typescript
import { 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
- 对象类型的校验
typescript
const descriptor = {
urls: {
type: "array",
required: true,
defaultField: { type: "string" },
},
};
// => 相当于校验:
// type Type = {
// url: string;
// }[]
当然,也可以通过枚举的方式一一指定 array 中的每个元素。但是只适用于有限长度的 array:
typescript
const 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];
- 复杂类型嵌套
比如对于以下类型:
typescript
type data = {
students: {
student1: string;
student2: string;
};
};
并且已有对 students
进行约束的 descriptor
:
typescript
const students = ["student1", "student2"];
const studentsDescriptor: Rules = students.reduce(
(acc, student) => ({
...acc,
[student]: {
type: "string",
},
}),
{} as Rules
);
那在引用 studentDescriptor
时,需要显式指定 students
的类型是 object
:
typescript
const descriptor: Rules = {
students: {
type: "object",
required: true,
defaultFields: studentsDescriptor,
},
};
若直接写 students: studentsDescriptor
,则校验该字段时会直接认为 students
字段类型为 string
。
校验的调用方式
typescript
import 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 需要额外进行配置(如 zhuanlan.zhihu.com/p/335290638... ,也是一种解决开头提出的问题的办法。
zod
不依托于框架,前后端均可使用,TS 友好,定义的类型可以跟校验规则互相转化。规则定义简便且语义化,相对来说更适合用于解决本人在文章开头提出的问题。但是更适合新项目(ts 4.5+ 且编译设置为严格模式),(不符合要求的)旧项目要强行使用 zod
可能需要花费不少修改成本。
async-validator
也不依托于框架,前后端均可使用,无法作为 TS 的 type 使用。定义较繁琐。