TypeScript interface validation using Joi

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


    Joi & TypeScript.  A love story


    Introduction


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


    A little background


    Now I am the only backend developer (namely, writing code) on the project. Functionality is not the essence, but the key essence is a rather long profile with personal data. The speed and quality of the code is based on my little experience of working independently on projects from scratch, even less experience working with JS (only the 4th month), and along the way, very crookedly obliquely, I write in TypeScript (hereinafter - TS). Dates are compressed, rolls are compressed, edits are constantly arriving and it turns out to write business logic code first, and then the interfaces on top. Nevertheless, a technical duty is capable of catching up and tapping on the cap, which, approximately, has happened to us.


    After 3 months of work on the project, I finally agreed with my colleagues to switch to a single dictionary so that the properties of the object are named and written the same everywhere. Under this business, of course, I undertook to write an interface and got stuck tightly with it for two business days.


    Problem


    A simple user profile will be an abstract example.


    • First The zero step of a good developer:describe data write tests;
    • First step:write tests Describe data
    • and so on.

    Suppose tests have already been written for 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, here everything is clear and extremely simple. All this code, as we recall, on the backend, or rather, in the api, that is, the user is created based on data that came over the network. Thus, we need to validate incoming data and help Joi in this:


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

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


    const joiUserValidator: IUserWe can’t just indicate , because it Joiuses its own data types, which generates view errors when compiling Type 'NumberSchema' is not assignable to type 'number'. But there must be a way to perform validation on the interface?
    I went online with this question


    Perhaps I didn’t google it correctly, or studied the answers poorly, but all the decisions came down to a lib extractTypesand some kind of fierce bicycles, like this :


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

    Decision


    Use third-party libraries


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


    However, there was an interest to figure it out yourself, to understand better the work of TS, and nothing was urging to solve the problem immediately.


    Get all properties


    Since I had no previous work 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 = {
      [key in keyof T];
    }
    const evan: UserKeys = {
      name: 'Evan',
      age: 32,
      phone: 791234567890
    };
    const joiUser: UserKeys = {
      name: Joi.string(),
      age: Joi.number(),
      phone: Joi.alternatives([Joi.string(), Joi.number()])
    };

    TypeScriptunder rather tricky and mysterious conditions, it allows you to get, for example, keys from the interface, as if it is a normal JS-object, however, only in design typeand through key in keyof Tand only through generics. As a result of the type operation UserKeys, all objects that implement the 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 give a clear indication of the types of values.


    Here is another interesting case that I could not use. Perhaps you can tell me why this is necessary (although I partially guess, there is not enough applied example):


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

    Use variable types


    You can explicitly set the types, and using "OR" and extracting the properties, get a locally working 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 = {
      [key in keyof T];
    }
    const olex: UserKeys = {
      name: 'Olex',
      age: 67,
      phone: '79998887766'
    };
    const joiUser: UserKeys = {
      name: Joi.string(),
      age: Joi.number(),
      phone: Joi.alternatives([Joi.string(), Joi.number()])
    };

    The problem with this code appears 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 will be - simple or Joi. This may cause an error when trying to perform mathematical operations with a field that is expected as 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.NumberSchemabecause age can be not only number. For that fought for it and ran.


    Combine two solutions into one?


    Somewhere at this point, the working day was approaching its logical conclusion. I took a breath, drank coffee, and erased the fucking fuck. It’s necessary to read these your Internet less! The time has cometake a shotgun and brainwash:


    1. An object must be formed with explicit value types;
    2. You can use generics to throw types into a single interface;
    3. Generics support default types;
    4. The design is typeclearly capable of something else.

    We write the 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 with a light heart IUserJoior worse. A more limited option is to get similar behavior:


    type IUserJoi = IUser;

    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 it in Joi.objectI had to fight with an error TS2345and the simplest solution turned out to be as any. I think this is not a critical assumption, because the above object is still on the interface.


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

    Компилится, на месте использования выглядит аккуратно и при отсутствии особых условий всегда устанавливает типы по умолчанию! Красота…
    sadness, trouble
    … на что я потратил два рабочих дня


    Резюмирование


    Какие выводы из всего этого можно сделать:


    1. Очевидно, я не научился находить ответы на вопросы. Наверняка при удачном запросе это решение (а то и ещё лучше) находится в первой 5ке ссылок поисковика;
    2. Переключиться на статическое мышление с динамического не так просто, гораздо чаще я просто забиваю на такое копошение;
    3. Дженерики — крутая штука. На хабре и стековерфлоу полно велосипедов неочевидных решений для построения сильной типизации… вне рантайма.

    Что мы выиграли:


    1. При изменении интерфейса отваливается весь код, включая валидатор;
    2. В редакторе появились подсказки по именам свойств и типам значений объекта для написания валидатора;
    3. The lack of obscure third-party libraries for the same purpose;
    4. Joi rules will be applied only where necessary, in other cases, default types;
    5. If someone wants to change the type of the value of some property, then with the correct organization of the code, he will fall into the place where all the types associated with this property are gathered together;
    6. We learned to beautifully and simply hide the generics behind the abstraction type, visually unloading the code from monstruson designs.

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


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


    Also popular now: