我使用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>'
失败
虽然错误消息是有意义的,因为Version
是string
的专门化,我想允许调用者调用函数与任何字符串,然后做一个运行时验证,将检查正则表达式。我仍然希望有一些输入信息,所以我不希望将输入定义为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-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) => {},
),
),
}
对于我的用例来说,定义一个没有标记的输入类型和一个细化作为该类型与标记的交集,例如:
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);
}