使用Joi进行TypeScript接口验证

如何花两天时间多次重写同一代码的故事。


Joi & TypeScript. A love story


参赛作品


在本文中,我将省略有关Hapi,Joi,路由和validate: { payload: ... }详细信息validate: { payload: ... } ,这意味着您已经了解它的含义以及术语,例如“接口”,“类型”等。 。 在这些方面的训练,我只会告诉您一个回合制策略,而不是最成功的策略。


一点背景


现在,我是该项目上唯一的后端开发人员(即,编写代码)。 功能不是本质,但是关键本质是对个人数据的较长描述。 代码的速度和质量是基于我很少从头开始独立处理项目的经验,甚至没有使用JS的经验(仅第4个月),并且一路走来非常歪斜,我是用TypeScript编写的(以下简称TS)。 日期被压缩,滚动被压缩,编辑不断到达,结果证明首先要编写业务逻辑代码,然后是最上面的接口。 然而,技术责任能够赶上并敲击瓶盖,这大约发生在我们身上。


在进行了3个月的项目工作后,我终于与同事们同意切换到单个词典,以便该对象的属性在任何地方都被命名和编写相同的内容。 当然,在这项工作下,我承诺编写一个界面,并紧紧地坚持了两个工作日。


问题


一个简单的用户配置文件将是一个抽象示例。


  • 首先 优秀开发人员的零步骤: 描述数据 编写测试;
  • 第一步: 编写测试 描述数据
  • 等等。

假设已经为该代码编写了测试,但仍然需要描述数据:


 interface IUser { name: string; age: number; phone: string | number; } const aleg: IUser = { name: 'Aleg', age: 45, phone: '79001231212' }; 

好吧,这里的所有内容都很清楚而且非常简单。 我们记得,所有这些代码都是在后端,或者在api中,也就是说,用户是基于网络上的数据创建的。 因此,我们需要验证传入的数据并在以下方面帮助Joi:


 const joiUserValidator = { name: Joi.string(), age: Joi.number(), phone: Joi.alternatives([Joi.string(), Joi.number()]) }; 

解决方案“在额头上”已准备就绪。 这种方法的明显缺点是验证器与接口完全分离。 如果在应用程序的生命周期中字段更改/添加或字段类型更改,则需要手动跟踪此更改并在验证器中指出。 我认为,除非有任何问题,否则不会有这样负责任的开发人员。 此外,在我们的项目中,调查问卷由三个嵌套级别的50多个字段组成,要了解这一点非常困难,即使是全心全意地了解。


我们根本无法指定const joiUserValidator: IUser ,因为Joi使用其数据类型,当编译类型Type 'NumberSchema' is not assignable to type 'number'类型时,它会产生错误。 但是必须有一种在接口上执行验证的方法吗?


也许我没有正确搜索它,或者对答案的研究不力,但是所有的决定都extractTypesextractTypes和某种凶猛的自行车,例如:


 type ValidatedValueType<T extends joi.Schema> = T extends joi.StringSchema ? string : T extends joi.NumberSchema ? number : T extends joi.BooleanSchema ? boolean : T extends joi.ObjectSchema ? ValidatedObjectType<T> : /* ... more schemata ... */ never; 

解决方案


使用第三方库


为什么不呢 当我向人们询问我的任务时,我收到了一个答案,后来又在这里的评论中(感谢keenondrums )链接到这些库:
https://github.com/typestack/class-validator
https://github.com/typestack/class-transformer


但是,有兴趣自己弄清楚,以更好地理解TS的工作,并且没有人敦促立即解决问题。


获取所有属性


由于我以前没有使用过静态函数,因此以上代码在类型中使用三元运算符方面发现了America。 幸运的是,不可能将其应用到项目中。 但是我发现了另一辆有趣的自行车:


 interface IUser { name: string; age: number; phone: string | number; } type UserKeys<T> = { [key in keyof T]; } const evan: UserKeys<IUser> = { name: 'Evan', age: 32, phone: 791234567890 }; const joiUser: UserKeys<IUser> = { name: Joi.string(), age: Joi.number(), phone: Joi.alternatives([Joi.string(), Joi.number()]) }; 

在相当棘手和神秘的条件下, TypeScript允许您从接口中获取键,就好像它是普通的JS对象一样,但是,只能在type构造中以及通过key in keyof T以及仅通过泛型来获取。 作为UserKeys类型的结果,实现接口的所有对象应具有相同的属性集,但是值的类型可以是任意的。 这包括IDE中的提示,但仍未明确指示值的类型。


这是另一个我无法使用的有趣案例。 也许您可以告诉我为什么这样做是有必要的(尽管我部分猜测,但是没有足够的应用示例):


 interface IUser { name: string; age: number; phone: string | number; } interface IUserJoi { name: Joi.StringSchema, age: Joi.NumberSchema, phone: Joi.AlternativesSchema } type UserKeys<T> = { [key in keyof T]: T[key]; } const evan: UserKeys<IUser> = { name: 'Evan', age: 32, phone: 791234567890 }; const userJoiValidator: UserKeys<IUserJoi> = { name: Joi.string(), age: Joi.number(), phone: Joi.alternatives([Joi.string(), Joi.number()]) }; 

使用变量类型


您可以显式设置类型,并使用“ OR”并提取属性,以获取本地工作代码:


 type TString = string | Joi.StringSchema; type TNumber = number | Joi.NumberSchema; type TStdAlter = TString | TNumber; type TAlter = TStdAlter | Joi.AlternativesSchema; export interface IUser { name: TString; age: TNumber; phone: TAlter; } type UserKeys<T> = { [key in keyof T]; } const olex: UserKeys<IUser> = { name: 'Olex', age: 67, phone: '79998887766' }; const joiUser: UserKeys<IUser> = { name: Joi.string(), age: Joi.number(), phone: Joi.alternatives([Joi.string(), Joi.number()]) }; 

当我们想从数据库中拾取有效对象时,就会出现此代码的问题,也就是说,TS事先不知道哪种类型的数据将是简单数据还是Joi。 尝试在期望为number的字段上执行数学运算时,这可能会导致错误:


 const someUser: IUser = getUserFromDB({ name: 'Aleg' }); const someWeirdMath = someUser.age % 10; // error TS2362: The left-hand side of an arithmetic operation must be of type'any', 'number', 'bigint' or an enum type 

此错误来自Joi.NumberSchema因为age不仅可以是number 。 他们为之奋斗并遇到的。


将两种解决方案合二为一吗?


在这一点上,工作日正在接近其逻辑结论。 我屏住呼吸,喝了咖啡,抹去了他妈的。 有必要少读这些书,以减少您的互联网! 时间到了 a弹枪 洗脑:


  1. 一个对象必须由显式的值类型组成。
  2. 您可以使用泛型将类型放入单个接口中。
  3. 泛型支持默认类型;
  4. type构造显然具有其他功能。

我们使用默认类型编写通用接口:


 interface IUser < TName = string, TAge = number, TAlt = string | number > { name: TName; age: TAge; phone: TAlt; } 

对于Joi,您可以创建第二个接口,以这种方式继承主要接口:


 interface IUserJoi extends IUser < Joi.StringSchema, Joi.NumberSchema, Joi.AlternativesSchema > {} 

这还不够好,因为下一个开发人员可以轻松地扩展IUserJoi或更糟。 一个更有限的选择是获得类似的行为:


 type IUserJoi = IUser<Joi.StringSchema, Joi.NumberSchema, Joi.AlternativesSchema>; 

我们尝试:


 const aleg: IUser = { name: 'Aleg', age: 45, phone: '79001231212' }; const joiUser: IUserJoi = { name: Joi.string(), age: Joi.number(), phone: Joi.alternatives([Joi.string(), Joi.number()]) }; 

UPD:
要结束在Joi.object我必须解决错误TS2345 ,并且最简单的解决方案是as any 。 我认为这不是一个关键的假设,因为上述对象仍在界面上。


 const joiUserInfo = { info: Joi.object(joiUser as any).required() }; 

它可以编译,在使用位置看起来很整洁,并且在没有特殊条件的情况下始终会设置默认类型! 美女...
-
...我花了两个工作日


总结


从这一切可以得出什么结论:


  1. 显然,我没有学习如何找到问题的答案。 当然,有了成功的请求,此解决方案(甚至更好)将出现在搜索引擎的前5k链接中;
  2. 从动态转变为静态思维并非易事,更多的是,我只是对这种蜂拥而至。
  3. 泛型很酷。 在Habr和stackoverflow已满 自行车 在运行时之外构建强类型的非显而易见的解决方案。

我们赢了:


  1. 更改接口时,包括验证程序在内的所有代码都会丢失;
  2. 在编辑器中,有关编写验证器的属性名称和对象值类型的提示;
  3. 出于相同目的,缺乏晦涩的第三方库;
  4. Joi规则仅在必要时才适用,在其他情况下为默认类型;
  5. 如果某人想更改属性的值类型,则使用正确的代码组织,他将去到与该属性关联的所有类型都聚集在一起的地方。
  6. 我们学会了将美丽的泛型简单地隐藏在type抽象的后面,从视觉上从怪异的结构中卸载代码。

道德:经验是无价的;在其他方面,有一张世界地图。


您可以看到,触摸并运行最终结果:
https://repl.it/@Melodyn/Joi-by-interface

Source: https://habr.com/ru/post/zh-CN450238/


All Articles