最近写的一个项目对导出的 json 数据格式要求较为严格,因此在测试数据格式上花了很多时间。此处对前后使用过的数据类型校验工具进行记录。

另外,虽然题目中说是 JS 中的类型校验,但是因个人开发习惯,以下涉及代码的部分均使用 TS

探究的产生

在校验数据类型的需求产生之后,有一个很大的问题摆在我面前:原先我已经定义了一套 Type 用于约束数据类型,此时又要各自在前后端中对数据进行类型校验,那最坏可能要同时维护三套类型定义,分别是 TS 中定义的类型、用于前端校验的类型定义、用于后端校验的类型定义,这显然增加了维护成本。正因如此,我寄望于能仅使用一套类型定义,来同时完成以上三种对类型定义的需求。

提前声明,本人因为一些原因,在项目中并没有实际解决以上的问题,但是确实找到了理论上可行(但是不适用于本项目)的方法。因为在这个问题上反复折腾了很久,所以现在先把这个问题抛出来,方便后文引用,也欢迎有相关实践经验的朋友找我交流更好的方案。

midwayjs 自带的校验

由于项目后端采用 midwayjs 开发,而 midwayjs 自带参数校验功能,其官方文档详见:https://midwayjs.org/docs/extensions/validate 。其 validate 主要的形式就是以 class 形式定义属性,再用装饰器对属性的类型进行约束。

个人的使用感受是定义较为繁琐,且前端似乎无法直接引用该 class 定义的类型。该方案更适用于 midwayjs 后端参数的校验。

校验定义的 example

  1. 简单的校验
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。

  1. 复杂对象的校验
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 就好。

  1. 复用类型校验
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,感兴趣的朋友可以自行查阅官网文档。

校验的调用方式

  1. 通过对函数入参指定参数的 type,隐式进行校验
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 进行类型校验。若校验不通过,则会直接报错。

  1. 通过调用 validate 函数,显式进行校验
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 的官方文档地址:https://zod.dev/

重要的 tips!

在文档的 https://zod.dev/?id=requirements ,提出了使用 zod 的使用条件。那就是:

  1. TypeScript 4.5+

  2. tsconfig.json 中必须设置 compilerOptions.strict 为 true

在本人所有类型都写好之后,才注意到这个第二点……当时调用 z.infer 导出 type 的时候出现了爆红,另外我这边的类型提示莫名默认将所有属性变成了 optional (而其他人拉下来的时候却是正常的必选,至今不知道问题出在哪)。而且当项目里配置改成 strict 之后,项目很多其他地方全爆红了,不得已搁置了这个方法。各位使用前一定要检查自己的项目符不符合要求。

校验定义的 example

  1. 常用类型的校验
import { z } from "zod";

const User = z.object({
  username: z.string().len(1),
});

可以看出 zod 的定义形式很灵活,写起来的形式也很符合平时函数式编程的直觉。

  1. Record 类型的支持

zod 直接提供了 z.record 这个 api,而本文提到的其他两个库对此似乎并没有比较好的支持(或者说文档说明并不完善

const NumberCache = z.record(z.number());

type NumberCache = z.infer<typeof NumberCache>;
// => { [k: string]: number }
  1. 支持 pick / omit / partial

校验的调用方式

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 }

如上,可以调用 parsesafeParse 对数据进行校验。不同的是,parse 校验不通过会直接抛出错误,而 safeParse 不会直接抛错,而是通过 success 字段标记校验状态。

实现校验规则与 TS 类型一体化的实践

相关详细文档见:https://zod.dev/?id=recursive-types

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 跟框架无关,前后端均可使用。

此外,这部分也展示了类型嵌套的处理方法,此处不多赘述了。

坑点

  1. 如开头 tips 所述,可能会因为项目不符合 zod 的使用条件,碰到项目爆红的情况。

  2. 对于非空数组叠了个 nonEmpty 的 buff,但是这很有可能跟定义的 TS 类型冲突

const nonEmptyStrings = z.string().array().nonempty();
// the inferred type is now
// [string, ...string[]]

如上所示,一般来说这种数组类型也不会特意定义成 [string, ...string[]] 的形式,大概率也就是 string[],但是这两个类型在 zod 里是不兼容的。这个问题本人还排查了好一会,最后才发现竟然是这里的问题……

  1. 要求必填但是内容可能为空的字符串的情况不好解决

默认 z.string() 就是要求该字段必填且类型为 string,而这样是不能通过空字符串的校验的。但是 zod 的 github issue 里有人提出过这个问题,并且给了一些解决办法,可以去查查

async-validator

这个库也是 antd 中对 form 进行校验时使用的底层库。跟 zod 同样跨平台,不过使用起来比较繁琐,定义相对较绕,心智负担较大。

严格来说 antd 应该是间接引用。antd 直接使用的是 rc-field-formhttps://github.com/ant-design/ant-design/blob/master/components/form/Form.tsx#L2C52-L2C53) ,而rc-field-form 实现 validate 又是基于 async-validatorhttps://github.com/react-component/field-form/blob/master/src/utils/validateUtil.ts#L1)

async-validator 官方文档见:https://github.com/yiminghe/async-validator

校验定义的 example

  1. 简单类型的校验
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;而自定义校验的话可以用 validatorasyncValidator

  1. 对象类型的校验
const descriptor = {
  urls: {
    type: "array",
    required: true,
    defaultField: { type: "string" },
  },
};

// => 相当于校验:
// type Type = {
//   url: string;
// }[]

当然,也可以通过枚举的方式一一指定 array 中的每个元素。但是只适用于有限长度的 array:

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];
  1. 复杂类型嵌套

比如对于以下类型:

type data = {
  students: {
    student1: string;
    student2: string;
  };
};

并且已有对 students 进行约束的 descriptor

const students = ["student1", "student2"];
const studentsDescriptor: Rules = students.reduce(
  (acc, student) => ({
    ...acc,
    [student]: {
      type: "string",
    },
  }),
  {} as Rules
);

那在引用 studentDescriptor 时,需要显式指定 students 的类型是 object

const descriptor: Rules = {
  students: {
    type: "object",
    required: true,
    defaultFields: studentsDescriptor,
  },
};

若直接写 students: studentsDescriptor,则校验该字段时会直接认为 students 字段类型为 string

校验的调用方式

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 需要额外进行配置(如 https://zhuanlan.zhihu.com/p/335290638) ,也是一种解决开头提出的问题的办法。

zod 不依托于框架,前后端均可使用,TS 友好,定义的类型可以跟校验规则互相转化。规则定义简便且语义化,相对来说更适合用于解决本人在文章开头提出的问题。但是更适合新项目(ts 4.5+ 且编译设置为严格模式),(不符合要求的)旧项目要强行使用 zod 可能需要花费不少修改成本。

async-validator 也不依托于框架,前后端均可使用,无法作为 TS 的 type 使用。定义较繁琐。