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);
}
