网站搜索

typescript - 如何在输入数据上使用 io-ts 的类型细化?


我正在使用 io-ts 来定义我的 TypeScript 类型,以便从单一来源进行运行时类型验证和类型声明。

在这个用例中,我想定义一个具有字符串成员的接口,并且该成员应根据正则表达式进行验证,例如版本字符串。因此,有效输入应如下所示:

{version: '1.2.3'}

实现这一点的机制似乎是通过品牌类型,我想出了这个方法:

import { isRight } from 'fp-ts/Either';
import { brand, Branded, string, type, TypeOf } from 'io-ts';

interface VersionBrand {
  readonly Version: unique symbol; // use `unique symbol` here to ensure uniqueness across modules / packages
}

export const TypeVersion = brand(
  string, // a codec representing the type to be refined
  (value: string): value is Branded<string, VersionBrand> =>
    /^\d+\.\d+\.\d+$/.test(value), // a custom type guard using the build-in helper `Branded`
  'Version' // the name must match the readonly field in the brand
);

export const TypeMyStruct = type({
  version: TypeVersion,
});

export type Version = TypeOf<typeof TypeVersion>;
export type MyStruct = TypeOf<typeof TypeMyStruct>;

export function callFunction(data: MyStruct): boolean {
  // type check
  const validation = TypeMyStruct.decode(data);
  return isRight(validation);
}

这对于我的 callFunction 方法中的类型验证如预期般工作,但我无法使用常规对象调用该函数,即以下内容无法编译:

callFunction({ version: '1.2.3' });

这会导致错误 Type 'string' is not assignable to type 'Branded'.

虽然错误信息是有道理的,但因为 Version 是 string 的一个特化,我希望允许调用者使用任何字符串来调用该函数,然后在运行时进行验证,以检查是否符合正则表达式。但我仍希望有一些类型信息,所以我不想将输入定义为 any。

理想情况下,应有一种方法可以从 Version 推导出一个版本 VersionForInput,该版本使用品牌字段的原始数据类型,因此它将等同于:

interface VersionForInput { version: string }

当然我可以明确声明它,但这意味着在某种程度上会重复类型定义。

问题:在 io-ts 中,有没有办法从类型的品牌化版本派生出非品牌化版本?对于这种用例,使用品牌化类型真的是正确的选择吗?目标是在原始类型的基础上进行额外的验证(例如对字符串值进行额外的正则表达式检查)。

答案#1

我觉得你问了两类问题,所以我会尽量回答两个。

从品牌类型中提取基础类型

可以通过 Brand 实例上的 type 字段来内省正在被标记的 io-ts 编解码器。

TypeVersion.type // StringType

所以可以编写一个函数,它使用你的结构体类型并从基础创建一个编解码器。你也可以使用类似这样的方式提取类型:

import * as t from 'io-ts';
type BrandedBase<T> = T extends t.Branded<infer U, unknown> ? U : T;
type Input = BrandedBase<Version>; // string

使用品牌类型

但我认为,与其用这种方式定义事物,我宁可定义我的结构体输入类型,然后定义一个细化,这样你只需要指定从输入中细化的编解码器部分。这将使用更新的 io-ts API。

import { pipe } from 'fp-ts/function';
import * as D from 'io-ts/Decoder';
import * as t from 'io-ts';
import * as E from 'fp-ts/Either';

const MyInputStruct = D.struct({
  version: D.string,
});

interface VersionBrand {
  readonly Version: unique symbol; // use `unique symbol` here to ensure uniqueness across modules / packages
}
const isVersionString = (value: string): value is t.Branded<string, VersionBrand> => /^\d+\.\d+\.\d+$/.test(value);
const VersionType = pipe(
  D.string,
  D.refine(isVersionString, 'Version'),
);

const MyStruct = pipe(
  MyInputStruct,
  D.parse(({ version }) => pipe(
    VersionType.decode(version),
    E.map(ver => ({
      version: ver,
    })),
  )),
);

这将首先定义输入类型,然后在输入类型上定义一个细化,以作为你更严格的内部解码器。如果你有其他值,可以通过 E.map 使用 ...rest 传递它们,以避免重复操作自己。

要回答“我是否应该使用品牌类型”这个问题,我认为你应该使用,但我想提供一种关于如何使用你的io-ts验证的不同视角。有一篇有趣的文章讨论了在应用程序的边缘使用解析逻辑,这样你可以只解析一次,并在应用程序的核心中依赖更强的类型。

因此,我建议您尽早解析 Version 字符串,然后在应用的其他地方使用品牌类型。例如,如果版本作为命令行参数传入,您可以这样做:

import { pipe } from 'fp-ts/function';
import * as t from 'io-ts';
import * as E from 'fp-ts/Either';

// Version being the branded type
function useVersion(version: Version) {
  // Does something with the branded type
}

function main (args: unknown) {
  pipe(
    MyStructType.decode(args) // accepts unknown
    // Map is called on Right only and passes Lefts along so you can
    // focus in on specifically what to do if validation succeeded
    E.map(({ version }) => useVersion(version)),

    // later on maybe handle Left
    E.fold(
      (l: t.Errors): void => console.error(l),
      (r) => {},
    ),
  ),
}

答案#2

对我的使用情况来说运作良好的是定义一个没有品牌的输入类型,并将精炼作为该类型与品牌的交集,例如:

export const TypeMyStructIn = type({
  version: string,
});

export const TypeMyStruct = intersection([
  TypeMyStructIn,
  type({
    version: TypeVersion,
  }),
]);

export type Version = TypeOf<typeof TypeVersion>;
export type MyStruct = TypeOf<typeof TypeMyStruct>;
export type MyStructIn = TypeOf<typeof TypeMyStructIn>;

export function callFunction(data: MyStructIn): boolean {
  // type check
  const validation = TypeMyStruct.decode(data);
  return isRight(validation);
}