ts-runtime-checks
一个自动从类型生成验证代码的TypeScript转换器。可以将其视为类似ajv和zod的验证库,但它完全依赖于TypeScript编译器,并按需生成原生JavaScript代码。这带来了许多优势:
- 仅仅是类型 - 无需样板代码或模式定义。
- 只在你认为合适的地方进行验证。
- 代码在转译阶段生成,可以被V8轻松优化。
- 功能强大 - 建立在图灵完备的TypeScript类型系统之上。
以下是一些你可以在playground中尝试的例子:
断言函数参数:
// 特殊的`Assert`类型会被检测并生成验证代码
function greet(name: Assert<string>, age: Assert<number>): string {
return `Hello ${name}, you are ${age} years old!`;
}
// 转译为:
function greet(name, age) {
if (typeof name !== "string") throw new Error("Expected name to be a string");
if (typeof age !== "number") throw new Error("Expected age to be a number");
return `Hello ${name}, you are ${age} years old!`;
}
检查值是否为特定类型:
interface User {
name: string;
age: Min<13>;
}
const maybeUser = {name: "GoogleFeud", age: "123"};
// `is`函数转译为验证代码
const isUser = is<User>(maybeUser);
// 转译为:
const isUser = typeof maybeUser === "object" && maybeUser !== null && typeof maybeUser.name === "string" && typeof maybeUser.age === "number" && maybeUser.age > 13;
模式匹配:
type WithValue = {value: string};
// `createMatch`函数创建一个模式匹配函数
const extractString = createMatch<string>([
(value: string | number) => value.toString(),
({value}: WithValue) => value,
() => {
throw new Error("Could not extract string.");
}
]);
//转译为:
const extractString = value_1 => {
if (typeof value_1 === "string") return value_1.toString();
else if (typeof value_1 === "number") return value_1.toString();
else if (typeof value_1 === "object" && value_1 !== null) {
if (typeof value_1.value === "string") {
let {value} = value_1;
return value;
}
}
throw new Error("Could not extract string.");
};
使用方法
npm i --save-dev ts-runtime-checks
与ts-patch一起使用
npm i --save-dev ts-patch
并在tsconfig.json中添加ts-runtime-checks转换器:
"compilerOptions": {
//... 其他选项
"plugins": [
{ "transform": "ts-runtime-checks" }
]
}
之后,你可以使用tspc
CLI命令来转译你的TypeScript代码。
与ts-loader一起使用
const TsRuntimeChecks = require("ts-runtime-checks").default;
options: {
getCustomTransformers: program => {
before: [TsRuntimeChecks(program)];
};
}
与ts-node一起使用
要在ts-node中使用转换器,你需要在tsconfig.json
中更改编译器:
npm i --save-dev ts-patch
"ts-node": {
"compiler": "ts-patch"
},
"compilerOptions": {
"plugins": [
{ "transform": "ts-runtime-checks" }
]
}
ts-runtime-checks
深入解析
标记
标记是被转换器检测到的TypeScript类型别名。这些类型并不代表实际的值,而是告诉转换器生成什么代码。可以将它们视为函数!
迄今为止最重要的标记是Assert<T>
,它告诉转译器验证类型T
。还有一些实用
标记,可以在Assert
标记内部使用,以某种方式自定义验证或添加额外的检查。以下是所有实用标记的列表:
Check<Condition, Error, Id, Value>
- 检查Condition
对该值是否为真。NoCheck<Type>
- 不为提供的类型生成检查。ExactProps<Obj, removeExtra, useDeleteOperator>
- 确保值没有任何多余的属性。Expr<string>
- 将字符串转换为表达式。可用于需要JavaScript值的标记中。Infer<Type>
/Resolve<Type>
- 为类型参数创建验证。
该库还导出了一组内置的Check
类型别名,可以用于现有类型以添加额外的检查:
Min<Size>
/Max<Size>
- 检查数字是否在范围内。Int
/Float
- 将数字限制为整数/浮点数。Matches<Regex>
- 检查值是否匹配一个模式。MaxLen<Size>
/MinLen<Size>
/Length<Size>
- 用于任何具有length
属性的东西,检查其是否在范围内。Eq
- 将值与提供的表达式进行比较。Not
- 对Check
取反。
Assert<Type, Action>
Assert
标记通过添加在运行时执行的验证代码来断言一个值是提供的类型。如果值不匹配类型,代码将根据Action
的不同返回一个值或抛出一个错误:
- 类型字面量(
123
、"hello"
、undefined
、true
、false
)- 将返回该字面量。 Expr<Type>
- 将返回该表达式。ErrorMsg<rawErrors>
- 将返回错误消息。ThrowError<ErrorType, rawErrors>
- 将抛出ErrorType
类型的错误。
如果rawErrors
为true,转换器将传递/返回一个如下所示的对象,而不是错误字符串:
{
// 导致错误的项的值
value: any;
// 值的名称
valueName: string;
// 预期类型的信息
expectedType: TypeData;
}
默认情况下,ThrowError<Error>
被传递给Assert
。
function onMessage(msg: Assert<string>, content: Assert<string, false>, timestamp: Assert<number, ThrowError<RangeError, true>>) {
// ...
}
function onMessage(msg, content, timestamp) {
if (typeof msg !== "string") throw new Error("Expected msg to be a string");
if (typeof content !== "string") return false;
if (typeof timestamp !== "number") throw new RangeError({value: timestamp, valueName: "timestamp", expectedType: {kind: 0}});
}
Check<Condition, Error, ID, Value>
通过提供包含JavaScript代码的字符串或函数引用,允许你创建自定义条件。
- 你可以使用
$self
变量获取当前正在验证的值。 - 你可以使用
$parent
函数获取值的父对象。你可以传递一个数字来获取嵌套的父对象。
Error
是一个自定义错误字符串消息,在检查失败时会显示。
type StartsWith<T extends string> = Check<`$self.startsWith("${T}")`, `to start with "${T}"`, "startsWith", T>;
function test(a: Assert<string & StartsWith<"a">>) {
return true;
}
// 转译为:
function test(a) {
if (typeof a !== "string" || !a.startsWith("a")) throw new Error('Expected a to be a string, to start with "a"');
return true;
}
你可以使用&
(交集)操作符组合检查:
// MaxLen和MinLen是库中包含的类型
function test(a: Assert<string & StartsWith<"a"> & MaxLen<36> & MinLen<3>>) {
return true;
}
// 转译为:
function test(a) {
if (typeof a !== "string" || !a.startsWith("a") || a.length > 36 || a.length < 3)
throw new Error('Expected a to be a string, to start with "a", to have a length less than 36, to have a length greater than 3');
return true;
}
当你想接收原始错误时,ID
和Value
类型参数会被使用。如果你不使用原始错误,就不需要使用它们。它们被传递给expectedType
对象,其中ID
是键,Value
是值:
function test(a: Assert<string & StartsWith<"a">, ThrowError<Error, true>>) {
return 1;
}
// 转译为:
function test(a) {
if (typeof a !== "string" || !a.startsWith(a)) throw new Error({value: a, valueName: "a", expectedType: {kind: 1, startsWith: "a"}});
return 1;
}
NoCheck<Type>
跳过对值的验证。
interface UserRequest {
name: string;
id: string;
child: NoCheck<UserRequest>;
}
function test(req: Assert<UserRequest>) {
// 你的代码...
}
// 转译为:
function test(req) {
if (typeof req !== "object" || req === null) throw new Error("Expected req to be an object");
if (typeof req.name !== "string") throw new Error("Expected req.name to be a string");
if (typeof req.id !== "string") throw new Error("Expected req.id to be a string");
}
ExactProps<Type, removeExtra, useDeleteOperator>
检查对象是否有任何"多余"的属性(不在类型上但在对象上的属性)。
如果removeExtra
为true,那么不会抛出错误,而是会将任何多余的属性原地从对象中删除。
如果 useDeleteOperator
为 true,则将使用 delete
运算符删除属性,否则该属性将被设置为 undefined。
function test(req: unknown) {
return req as Assert<ExactProps<{a: string; b: number; c: [string, number]}>>;
}
// 转译为:
function test(req) {
if (typeof req !== "object" || req === null) throw new Error("预期 req 是一个对象");
if (typeof req.a !== "string") throw new Error("预期 req.a 是一个字符串");
if (typeof req.b !== "number") throw new Error("预期 req.b 是一个数字");
if (!Array.isArray(req.c)) throw new Error("预期 req.c 是一个数组");
if (typeof req.c[0] !== "string") throw new Error("预期 req.c[0] 是一个字符串");
if (typeof req.c[1] !== "number") throw new Error("预期 req.c[1] 是一个数字");
for (let p_1 in req) {
if (p_1 !== "a" && p_1 !== "b" && p_1 !== "c") throw new Error("属性 req." + p_1 + " 是多余的");
}
return req;
}
Infer<Type>
你可以在类型参数上使用这个实用类型 - 转换器会遍历该类型参数所属函数的所有调用位置,找出实际使用的类型,创建所有可能类型的联合,并在函数体内进行验证。
export function test<T>(body: Assert<Infer<T>>) {
return true;
}
// 在 fileA.ts 中
test(123);
// 在 FileB.ts 中
test([1, 2, 3]);
// 转译为:
function test(body) {
if (typeof body !== "number")
if (!Array.isArray(body)) throw new Error("预期 body 是 number 或 number[] 中的一种");
else {
for (let i_1 = 0; i_1 < len_1; i_1++) {
if (typeof body[i_1] !== "number") throw new Error("预期 body[" + i_1 + "] 是一个数字");
}
}
return true;
}
Resolve<Type>
将类型参数传递给 Resolve<Type>
以将验证逻辑移至调用站点,在那里类型参数被解析为实际类型。
目前,这个标记有一些限制:
- 只能在
Assert
标记中使用(所以你不能在check
或is
中使用) - 只能在参数声明中使用(所以不能用于
as
断言) - 参数名称必须是一个标识符(不能解构)
- 不能用于剩余参数
function validateBody<T>(data: Assert<{body: Resolve<T>}>) {
return data.body;
}
const validatedBody = validateBody<{
name: string;
other: boolean;
}>({body: JSON.parse(process.argv[2])});
// 转译为:
function validateBody(data) {
return data.body;
}
const receivedBody = JSON.parse(process.argv[2]);
const validatedBody = (() => {
const data = {body: receivedBody};
if (typeof data.body !== "object" && data.body !== null) throw new Error("预期 data.body 是一个对象");
if (typeof data.body.name !== "string") throw new Error("预期 data.body.name 是一个字符串");
if (typeof data.body.other !== "boolean") throw new Error("预期 data.body.other 是一个布尔值");
return validateBody(data);
})();
转换
你也可以使用 Transform
标记在你的类型中描述转换。它接受一个函数引用、包含 JavaScript 代码的字符串,或两者的组合:
const timestampToDate = (ts: number) => new Date(ts);
const incrementAge = (age: number) => age + 1;
type User = {
username: string;
createdAt: Transform<typeof timestampToDate>;
age: Transform<["+$self", typeof incrementAge], string>;
};
推荐使用函数引用,因为所有类型都会为你推断。在上面的例子中,我们能够告诉 TypeScript createdAt
在转换为 Date
之前是 number
类型。然而,在 age
中,我们必须指定初始类型(string
),因为第一个转换是一个代码字符串。
一旦你有了可以转换的类型,你可以使用 transform
实用函数来实际执行转换:
const myUser: User = {
username: "GoogleFeud",
createdAt: 1716657364400,
age: "123"
}
console.log(transform<User>(myUser))
// 转译为:
let result_1;
result_1 = {};
result_1.createdAt = timestampToDate(myUser.createdAt);
result_1.age = incrementAge(+myUser.age);
result_1.username = myUser.username;
console.log(result_1);
transform
函数的第二个类型参数是一个 Action
,如果提供了它,类型将在转换之前进行验证。查看 Assert
部分了解所有可能的操作。
你也可以通过联合类型执行条件转换:
interface ConditionalTransform {
// "age" 可以是数字或字符串
age: number | Transform<typeof stringToNum>,
// "id" 可以是字符串或大于3的数字
id: Transform<typeof stringToNum> | Min<3> & Transform<"$self + 1">
}
transform<ConditionalTransform, ThrowError>({ age: "3", id: 12 })
// 转译为:
let result_1;
result_1 = {};
if (typeof value_1.id === "string") {
result_1.id = stringToNum(value_1.id);
} else if (typeof value_1.id === "number" && value_1.id >= 3) {
result_1.id = value_1.id + 1;
} else
throw new Error("期望 value.id 为字符串或数字,且大于3");
if (typeof value_1.age === "string") {
result_1.age = stringToNum(value_1.age);
} else if (typeof value_1.age === "number") {
result_1.age = value_1.age;
} else
throw new Error("期望 value.age 为字符串或数字");
你还可以使用 PostCheck
类型在值被转换后执行检查!查看 PostCheck
示例和其他非常复杂的条件转换在这个单元测试中
as
断言
你可以使用 as
类型断言来验证表达式中的值。转换器会记住哪些是安全可用的,因此你不会生成重复的验证代码。
interface Args {
name: string;
path: string;
output: string;
clusters?: number;
}
const args = JSON.parse(process.argv[2] as Assert<string>) as Assert<Args>;
// 转译为:
if (typeof process.argv[2] !== "string") throw new Error("期望 process.argv[2] 为字符串");
const value_1 = JSON.parse(process.argv[2]);
if (typeof value_1 !== "object" || value_1 === null) throw new Error("期望值为对象");
if (typeof value_1.name !== "string") throw new Error("期望 value.name 为字符串");
if (typeof value_1.path !== "string") throw new Error("期望 value.path 为字符串");
if (typeof value_1.output !== "string") throw new Error("期望 value.output 为字符串");
if (value_1.clusters !== undefined && typeof value_1.clusters !== "number") throw new Error("期望 value.clusters 为数字");
const args = value_1;
is<Type>(value)
对此函数的每次调用都会被替换为一个立即调用的箭头函数,如果值匹配类型则返回 true
,否则返回 false
。
const val = JSON.parse('["Hello", "World"]');
if (is<[string, number]>(val)) {
// val 保证是 [string, number] 类型
}
// 转译为:
const val = JSON.parse('["Hello", "World"]');
if (Array.isArray(val) && typeof val[0] === "string" && typeof val[1] === "number") {
// 你的代码
}
check<Type, rawErrors>(value)
对此函数的每次调用都会被替换为一个立即调用的箭头函数,它返回提供的值以及一个错误数组。
如果 rawErrors
为 true,原始错误数据将被推送到数组中,而不是错误字符串。
const [value, errors] = check<[string, number]>(JSON.parse('["Hello", "World"]'));
if (errors.length) console.log(errors);
// 转译为:
const value = JSON.parse('["Hello", "World"]');
const errors = [];
if (!Array.isArray(value)) errors.push("期望值为数组");
else {
if (typeof value[0] !== "string") errors.push("期望 value[0] 为字符串");
if (typeof value[1] !== "number") errors.push("期望 value[1] 为数字");
}
if (errors.length) console.log(errors);
createMatch<ReturnType, InputType>(function[], noDiscriminatedObjAssert)
创建一个匹配函数,该函数基于提供的函数对输入类型执行模式匹配。数组中的每个函数都是一个匹配分支,其中第一个参数的类型是该分支匹配的类型:
// 我们希望匹配函数返回字符串并接受一个数字
const resolver = createMatch<string, number>([
// 匹配 0 或 1 的分支
(_: 0 | 1) => "不多",
// 匹配小于 9 的任何数字的分支
(_: Max<9>) => "几个",
// 匹配任何尚未被捕获的数字的分支
(_: number) => "很多"
]);
// 转译为:
const resolver = value_1 => {
if (typeof value_1 === "number") {
if (value_1 === 1 || value_1 === 0) return "不多";
else if (value_1 < 9) return "几个";
else return "很多";
}
};
你也可以通过省略参数或给它 unknown
或 any
类型来设置默认的匹配分支:
const toNumber: (value: unknown) => number = createMatch<number>([
(value: string | boolean) => +value,
(value: number) => value,
(value: Array<string> | Array<number> | Array<boolean>) => value.map(v => toNumber(v)).reduce((val, acc) => val + acc, 0),
(value: unknown) => {
throw new Error("意外的值: " + value);
}
]);
// 转译为:
const toNumber = value_1 => {
if (typeof value_1 === "boolean") return +value_1;
else if (typeof value_1 === "string") return +value_1;
else if (typeof value_1 === "number") return value_1;
else if (Array.isArray(value_1)) {
if (value_1.every(value_2 => typeof value_2 === "string") || value_1.every(value_3 => typeof value_3 === "number") || value_1.every(value_4 => typeof value_4 === "boolean"))
return value_1.map(v => toNumber(v)).reduce((val, acc) => val + acc, 0);
}
throw new Error("意外的值: " + value_1);
};
如果discriminatedObjAssert
参数设置为true,那么对于有区分性的对象(具有字面量属性的对象),只会验证字面量属性。如果你已经验证了源对象或者知道它是正确的,可以使用这个选项。
解构
如果一个值是解构的对象或数组,那么只会验证被解构的属性或元素。
function test({
user: {
skills: [skill1, skill2, skill3]
}
}: Assert<
{
user: {
username: string;
password: string;
skills: [string, string?, string?];
};
},
undefined
>) {
// 你的代码
}
// 转译为:
function test({
user: {
skills: [skill1, skill2, skill3]
}
}) {
if (typeof skill1 !== "string") return undefined;
if (skill2 !== undefined && typeof skill2 !== "string") return undefined;
if (skill3 !== undefined && typeof skill3 !== "string") return undefined;
}
支持的类型和代码生成
string
和字符串字面量typeof value === "string"
或value === "literal"
number
和数字字面量typeof value === "number"
或value === 420
boolean
value === true || value === false
symbol
typeof value === "symbol"
bigint
typeof value === "bigint"
null
value === null
undefined
value === undefined
- 元组 (
[a, b, c]
)Array.isArray(value)
- 元组中的每个类型都会被单独检查。
- 数组 (
Array<a>
,a[]
)Array.isArray(value)
- 数组中的每个值都会通过
for
循环进行检查。
- 接口和对象字面量 (
{a: b, c: d}
)typeof value === "object"
value !== null
- 对象中的每个属性都会被单独检查。
- 类
value instanceof Class
- 枚举
- 联合类型 (
a | b | c
)- 可区分联合 - 联合中的每个类型必须有一个值是字符串或数字字面量。
- 函数类型参数
- 在函数内部作为一个大的联合类型,使用
Infer
工具类型。 - 在函数调用处使用
Resolve
工具类型。
- 在函数内部作为一个大的联合类型,使用
- 递归类型
- 为递归类型生成一个函数,验证代码在函数内部。
- 注意: 目前由于限制,递归类型中的错误信息会相对有限。
复杂类型
标记可以在类型别名中使用,所以你可以轻松创建常见模式的快捷方式:
组合检查:
// 将所有与数字相关的检查组合成一个类型
type Num<min extends number | undefined = undefined, max extends number | undefined = undefined, typ extends Int | Float | undefined = undefined> = number &
(min extends number ? Min<min> : number) &
(max extends number ? Max<max> : number) &
(typ extends undefined ? number : typ);
function verify(n: Assert<Num<2, 10, Int>>) {
// ...
}
// 转译为:
function verify(n) {
if (typeof n !== "number" || n < 2 || n > 10 || n % 1 !== 0) throw new Error("期望 n 是一个数字,大于2,小于10,且为整数");
}
从你的类型生成 JSON Schemas
通过使用jsonSchema
配置选项,转换器允许你将项目中使用的任何类型转换为JSON Schemas:
"compilerOptions": {
//... 其他选项
"plugins": [
{
"transform": "ts-runtime-checks",
"jsonSchema": {
"dist": "./schemas"
}
}
]
}
使用上述配置,您项目中的所有类型都将转换为JSON架构并保存在./schemas
目录中,每个类型都存储在不同的文件中。您还可以使用types
选项或typePrefix
选项来筛选类型:
"jsonSchema": {
"dist": "./schemas",
// 只有特定类型会被转换为架构
"types": ["User", "Member", "Guild"],
// 只有名称以特定前缀开头的类型会被转换为架构
"typePrefix": "$"
}
贡献
ts-runtime-checks
目前由一个人维护。欢迎并感谢任何贡献。如果您有任何问题或想提交拉取请求,请访问https://github.com/GoogleFeud/ts-runtime-checks