📜 ⬆️ ⬇️

Validation on TypeScript interface using Joi

The story of how to spend two days on rewriting the same code many times.


Joi & TypeScript. A love story


Introduction


In this article I will omit the details about Hapi, Joi, routing and validate: { payload: ... } , implying that you already understand what it is about, like the terminology, a la "interfaces", "types" and the like . I’ll tell you only about the step-by-step, not the most successful strategy of my own learning these things.


A bit of background


Now I am the only backend developer (writing code) on the project. Functionality is not the essence, but the key essence is a rather long profile with personal data. The speed of work and the quality of the code are tied to my little experience of independent work on projects from scratch, even less experience with JS (only 4th month) and, incidentally, very crookedly, I write in TypeScript (hereinafter - TS). The deadlines are compressed, the rolls are compressed, edits are constantly arriving and it turns out to first write the code of business logic, and then the interfaces from above. Nevertheless, technical debt is able to catch up and knock on the cap, which approximately happened to us.


After 3 months of working on the project, I finally agreed with my colleagues on the transition to a single vocabulary, so that the properties of the object were called and written everywhere in the same way. Under this business, of course, undertook to write the interface and tightly stuck with him for two working days.


Problem


A simple user profile will be used as an abstract example.



Assume that tests have already been written to this code, it remains to describe the data:


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

Well, everything is clear and very simple. All this code, as we remember, is on the backend, or rather, in api, that is, the user is created based on the data that came through the network. So we need to validate the incoming data and help Joi with it:


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

The solution "in the forehead" is ready. The obvious disadvantage of this approach is that the validator is completely detached from the interface. If during the life of the application the fields are changed / added or their type is changed, then this change will need to be manually tracked and indicated in the validator. I think these responsible developers will not be until something falls. In addition, in our project, the questionnaire consists of 50+ fields on three levels of nesting and it is extremely difficult to understand this, even knowing everything by heart.


We cannot simply specify const joiUserValidator: IUser , because Joi uses its own data types, which Type 'NumberSchema' is not assignable to type 'number' in compiling an error of the type Type 'NumberSchema' is not assignable to type 'number' . But there must be a way to validate the interface?


Probably, I incorrectly googled, or poorly studied the answers, but all the decisions came down to either extractTypes and some fierce bicycles, such as this :


 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; 

Decision


Use third-party libraries


Why not. When I asked people for their task, I received in one of the answers, and later, and here, in the comments (thanks to keenondrums ), links to these libraries:
https://github.com/typestack/class-validator
https://github.com/typestack/class-transformer


However, there was an interest to find out for myself, to understand better the work of TS, and nothing was pressing to solve the problem momentarily.


Get all properties


Since I had no previous affairs with statics, the above code discovered America in terms of using ternary operators in types. Fortunately, it was not possible to apply it in the project. But I found another interesting bike:


 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 with rather tricky and mysterious conditions allows you to get, for example, keys from the interface, as if this is a normal JS object, but only in the type construction and through the key in keyof T and only through generics. As a result of the work of the UserKeys type, all objects implementing interfaces should have the same set of properties, but the types of values ​​can be arbitrary. This includes hints in the IDE, but still does not allow unambiguously to identify the types of values.


There is another interesting case that could not be used. Perhaps you can tell why this is needed (although I partially guess that the application example is missing):


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

Use variable types


You can explicitly specify types, and using "OR" and extracting properties, you can get locally workable code:


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

The problem of this code manifests itself when we want to pick up a valid object, for example, from the database, that is, TS does not know in advance what type of data it will be - simple or Joi. This can cause an error when trying to perform math operations on a field that is expected to be a 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 

This error comes from Joi.NumberSchema because age can be more than just a number . For that fought for it and ran.


Connect two solutions into one?


Somewhere at this point, the working day came to a logical conclusion. I took a breath, drank coffee and erased this pornography to hell. It is necessary to read these your internet less! The time has come take a shotgun and Brainstorm:


  1. The object must be formed with explicit value types;
  2. You can use generics to push types into one interface;
  3. Generics support default types;
  4. The type construct is clearly capable of something else.

We write generic interface with default types:


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

For Joi, you could create a second interface, inheriting the main one in this way:


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

Not good enough, because the next developer can expand IUserJoi with a light heart or worse. A more limited option to get similar behavior:


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

We try:


 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:
To wrap in Joi.object I had to contend with the TS2345 error and the simplest solution was as any . I think this is not a critical assumption, because the object is higher all the same on the interface.


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

Compiles, at the place of use it looks neat and in the absence of special conditions always sets the types by default! Beauty…
-
... what I spent two working days


Summary


What conclusions can be drawn from all this:


  1. Obviously, I have not learned to find answers to questions. Surely with a successful query, this solution (or even better) is in the first 5 of the search engine links;
  2. Switching to static thinking from a dynamic is not so easy, much more often I just hammer on such a swarming;
  3. Generics are cool stuff. On Habré and glassflow flush full bicycle non-obvious solutions for building strong typing ... beyond runtime.

What we won:


  1. When the interface changes, all the code falls off, including the validator;
  2. Hints appeared in the editor about the names of the properties and the types of the object's values ​​for writing the validator;
  3. Lack of obscure third-party libraries for the same purpose;
  4. Joi rules will be applied only where it is needed, otherwise - default types;
  5. If someone wants to change the type of the value of a property, then with proper organization of the code, he will get to the place where all the types associated with this property are put together;
  6. We learned how to beautifully and simply hide the generics behind the type abstraction, visually unloading the code from monstrous constructions.

Moral: Experience is priceless, for the rest there is a map of the "World".


You can see, touch, run the final result:
https://repl.it/@Melodyn/Joi-by-interface


')

Source: https://habr.com/ru/post/450238/


All Articles