如何在输入数据上使用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<string, VersionBrand>'失败

虽然错误消息是有意义的,因为Versionstring的专门化,我想允许调用者调用函数与任何字符串,然后做一个运行时验证,将检查正则表达式。我仍然希望有一些输入信息,所以我不希望将输入定义为any

理想情况下,有一种方法可以从Version中派生出VersionForInput版本,该版本使用标记字段的原始数据类型,因此它相当于:

interface VersionForInput { version: string }

当然,我可以显式地声明它,但这将意味着在某种程度上复制类型定义。

问题:在io-ts中是否有一种方法可以从类型的已标记版本派生出未标记版本?对于这个用例来说,使用品牌字体是正确的选择吗?目标是在基本类型的基础上进行额外的验证(例如,对字符串值进行额外的regexp检查)。

我想你已经问了两个问题,所以我试着回答两个。

从标记类型中提取基类型

可以通过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-tsapi。

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) => {},
),
),
}

对于我的用例来说,定义一个没有标记的输入类型和一个细化作为该类型与标记的交集,例如:

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

最新更新