Project Icon

ts-runtime-checks

TypeScript运行时类型检查转换器

ts-runtime-checks是一个TypeScript转换器,可自动从类型定义生成运行时验证代码。它依托TypeScript编译器,无需额外模式定义,在转译阶段生成原生JavaScript代码。该项目支持灵活的验证选项和复杂类型检查,易于集成到现有TypeScript项目中。通过ts-runtime-checks,开发者可以简洁高效地实现运行时类型安全。

ts-runtime-checks

一个自动从类型生成验证代码的TypeScript转换器。可以将其视为类似ajvzod的验证库,但它完全依赖于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"undefinedtruefalse)- 将返回该字面量。
  • 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;
}

当你想接收原始错误时,IDValue类型参数会被使用。如果你不使用原始错误,就不需要使用它们。它们被传递给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 标记中使用(所以你不能在 checkis 中使用)
  • 只能在参数声明中使用(所以不能用于 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 "很多";
    }
};

你也可以通过省略参数或给它 unknownany 类型来设置默认的匹配分支:

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

项目侧边栏1项目侧边栏2
推荐项目
Project Cover

豆包MarsCode

豆包 MarsCode 是一款革命性的编程助手,通过AI技术提供代码补全、单测生成、代码解释和智能问答等功能,支持100+编程语言,与主流编辑器无缝集成,显著提升开发效率和代码质量。

Project Cover

AI写歌

Suno AI是一个革命性的AI音乐创作平台,能在短短30秒内帮助用户创作出一首完整的歌曲。无论是寻找创作灵感还是需要快速制作音乐,Suno AI都是音乐爱好者和专业人士的理想选择。

Project Cover

有言AI

有言平台提供一站式AIGC视频创作解决方案,通过智能技术简化视频制作流程。无论是企业宣传还是个人分享,有言都能帮助用户快速、轻松地制作出专业级别的视频内容。

Project Cover

Kimi

Kimi AI助手提供多语言对话支持,能够阅读和理解用户上传的文件内容,解析网页信息,并结合搜索结果为用户提供详尽的答案。无论是日常咨询还是专业问题,Kimi都能以友好、专业的方式提供帮助。

Project Cover

阿里绘蛙

绘蛙是阿里巴巴集团推出的革命性AI电商营销平台。利用尖端人工智能技术,为商家提供一键生成商品图和营销文案的服务,显著提升内容创作效率和营销效果。适用于淘宝、天猫等电商平台,让商品第一时间被种草。

Project Cover

吐司

探索Tensor.Art平台的独特AI模型,免费访问各种图像生成与AI训练工具,从Stable Diffusion等基础模型开始,轻松实现创新图像生成。体验前沿的AI技术,推动个人和企业的创新发展。

Project Cover

SubCat字幕猫

SubCat字幕猫APP是一款创新的视频播放器,它将改变您观看视频的方式!SubCat结合了先进的人工智能技术,为您提供即时视频字幕翻译,无论是本地视频还是网络流媒体,让您轻松享受各种语言的内容。

Project Cover

美间AI

美间AI创意设计平台,利用前沿AI技术,为设计师和营销人员提供一站式设计解决方案。从智能海报到3D效果图,再到文案生成,美间让创意设计更简单、更高效。

Project Cover

AIWritePaper论文写作

AIWritePaper论文写作是一站式AI论文写作辅助工具,简化了选题、文献检索至论文撰写的整个过程。通过简单设定,平台可快速生成高质量论文大纲和全文,配合图表、参考文献等一应俱全,同时提供开题报告和答辩PPT等增值服务,保障数据安全,有效提升写作效率和论文质量。

投诉举报邮箱: service@vectorlightyear.com
@2024 懂AI·鲁ICP备2024100362号-6·鲁公网安备37021002001498号