likes
comments
collection
share

支持多种“介质”转换的 TypeScript 运行时校验方案

作者站长头像
站长
· 阅读数 65

故事比较长,可以先点上🌟(星星)。

【X-Value】“介质中性”的TypeScript运行时类型库​

为什么要造“轮子”

当然一方面是因为造轮子是我的生活乐趣之一。

在过去的项目中,我们大量使用了 io-ts 来定义 API,利用 io-ts 的特性,可以同时解决静态类型和运行时类型的验证问题:

import * as t from 'io-ts';

const ObjectIdString = t.refinement(
  t.string,
  (value): value is string & {__nominal: 'object id string'} =>
    /^[\da-f]{24}$/.test(value),
);

const update_profile = define(
  t.type({
    id: ObjectIdString,
    name: t.string,
    description: t.description,
  }),
  t.void,
);

其中的 ObjectIdStringstring 的 refinement(精炼、细化),但实际使用中我们的需求不止如此:理想情况下,我们希望不仅能对 id 的字符串进行检测,还能将其自动转换为 ObjectId

在 io-ts 中,对应了 input 和 output 概念来处理相似的问题,实际上每一个 io-ts 的 Type 实例泛型都有三部分:AIO。其中 A 是默认类型,IO 分别对应了 input 和 output。如果要实现 ObjectId 的自动转换,可以这样:

import {ObjectId as BSONObjectId} from 'bson';
import * as t from 'io-ts';

const ObjectId = new t.Type<BSONObjectId, string, string>(
  'ObjectId',
  (value): value is BSONObjectId => value instanceof BSONObjectId,
  (input, context) =>
    typeof input === 'string' && BSONObjectId.isValid(input)
      ? t.success(new BSONObjectId(input))
      : t.failure(input, context),
  value => t.success(value.toHexString()),
);

细心的同学可以发现,虽然 io-ts 中区分了 input 和 output 类型,但设计上这两者通常都是一致的:都表示了某类值在同一种不同于当前环境的“介质”中的类型。通过单独的 input 类型,可以给使用者更大的灵活性,支持更宽松的输入数据(比如从 query string 中读到的值都是 string,但实际上可能需要先转换为 number)。

但是,这种抽象是存在一定限制的:

  1. 它假定了这些定义的 Type 只会在相同、或者相似的环境中使用(比如服务器端)。
  2. 同一个 Type 只能与一种其他“介质”进行转换。

而在实际使用中:我们可能希望 ObjectId 在浏览器中以是 string,在 API 参数中是 string,但是在服务器上使用时是 ObjectId;与此同时,我们又希望 Date 在 API 参数中是 string,但浏览器和服务器中都是 Date。

也就是说,理想情况下,我们需要的其实是同一个类型的值,可以根据自身表达的需要,在不同“介质”中以不同的值类型存在。

以上也是我另起炉灶搞 X-Value 的主要原因。

使用 X-Value 处理“介质”间转换

同样还是以 ObjectIdDate 为例,使用 X-Value 定义好原子类型(ObjectId)和介质(xDataMediumxServerMedium)后,只需要定义一次数据类型(Payload),就能统一在特定的节点(通常是各类 API 调用)完成数据转换,避免遗漏。

import * as x from 'x-value';

import {xDataMedium, xQueryMedium, xServerMedium} from './mediums';
import {ObjectId} from './types';

const Payload = x.object({
  id: ObjectId,
  date: x.Date,
  limit: x.number,
});

const DATA_PAYLOAD = {
  id: '000000000000000000000000',
  date: '1970-01-01T00:00:00.000Z',
  limit: 100,
};

const QUERY_PAYLOAD =
  'id=000000000000000000000000&date=1970-01-01T00:00:00.000Z&limit=100';

const SERVER_PAYLOAD = {
  id: new ObjectId('000000000000000000000000'),
  date: new Date('1970-01-01T00:00:00.000Z'),
  limit: 100,
};

const CLIENT_PAYLOAD = {
  id: '000000000000000000000000',
  date: new Date('1970-01-01T00:00:00.000Z'),
  limit: 100,
};

// 服务器端解析 API 参数(data object)
expect(Payload.transform(xDataMedium, xServerMedium, DATA_PAYLOAD)).toEqual(
  SERVER_PAYLOAD,
);

// 服务器端解析 API 参数(query string)
expect(Payload.transform(xQueryMedium, xServerMedium, DATA_PAYLOAD)).toEqual(
  SERVER_PAYLOAD,
);

// 服务器端序列化 API 数据
expect(Payload.transform(xServerMedium, xDataMedium, SERVER_PAYLOAD)).toEqual(
  DATA_PAYLOAD,
);

// 客户端解析 API 数据
expect(Payload.transform(xDataMedium, xClient, DATA_PAYLOAD)).toEqual(
  CLIENT_PAYLOAD,
);

当然,除开各种 transform/encode/decode 都是类型安全的以外,也支持通过类型运算获得静态类型:

type Payload = x.TypeOf<typeof Payload>;
type PayloadInData = x.MediumTypeOf<'x-data', typeof Payload>;
type PayloadInServer = x.MediumTypeOf<'x-server', typeof Payload>;

简单数据校验

X-Value 的类型间转换是通过一个特别的“value”概念实现的,其他“介质”中的值 decode 后成为“value”,重新再 encode 后又成为了其他“介质”中的值。所以上面的 transform 其实就是一组 decode + encode。

实际上,单纯作为运行时类型校验库使用时,我们通常只需要至多一种“介质”(比如 x.extendedJSONValue),甚至数据简单时直接使用“value”验证即可:

const Config = x.object({/* ... */});

const rawConfig = require('path-to-config');

// 作为 extended json value 读入
const config = Config.decode(x.extendedJSONValue, rawConfig);

// 直接验证值
Config.satisfies(rawConfig);

First-class Nominal Type(nominal 类型是一等公民)

作为资深 TypeScript 类型体操运动员,无处不在的 nominal 类型是不可避免地,其中最常见的场景应该就是各种 id 了:比如同样是 ObjectId,可以有 UserIdMessageIdChannelId 等。

const UserId = ObjectId.nominal<'user id'>();
const MessageId = ObjectId.nominal<'message id'>();
const ChannelId = ObjectId.nominal<'channel id'>();

X-Value 会在各种转换中保留 nominal 信息,确保类型强迫症得到满足(同时避免一些低级错误)。

const User = x.object({
  id: UserId,
  name: x.string,
});

User.encode(xServerMedium, {/* ... */}); // id 为 x.Nominal<ObjectId, 'user id'>;
User.encode(xClientMedium, {/* ... */}); // id 为 x.Nominal<string, 'object id string' | 'user id'>;

全面 100% 测试覆盖率

X-Value 是我的重点项目之一,坚持 100% statements/branches/functions/lines 测试覆盖率。并且作为失业程序员,有充裕的时间修复或者解答大家的问题,欢迎同学们吃螃蟹。

转载自:https://juejin.cn/post/7199659141017927738
评论
请登录