Validation de l'interface TypeScript à l'aide de Joi

L'histoire de comment passer deux jours à réécrire plusieurs fois le même code.


Joi & TypeScript. A love story


Entrée


Dans cet article, je vais omettre les détails sur Hapi, Joi, le routage et validate: { payload: ... } , ce qui implique que vous comprenez déjà de quoi il s'agit, ainsi que la terminologie, les «interfaces», les «types», etc. . Je vais seulement vous parler d'une stratégie au tour par tour, pas la plus réussie, de ma formation dans ces domaines.


Un peu de fond


Maintenant, je suis le seul développeur backend (à savoir, écrire du code) sur le projet. La fonctionnalité n'est pas l'essence, mais l'essence clé est un profil assez long avec des données personnelles. La vitesse et la qualité du code sont basées sur ma petite expérience de travail indépendant sur des projets à partir de zéro, encore moins d'expérience de travail avec JS (seulement le 4ème mois), et en cours de route, de manière très croisée, j'écris en TypeScript (ci-après - TS). Les dates sont compressées, les rouleaux sont compressés, les modifications arrivent constamment et il s'avère d'abord écrire du code logique métier, puis les interfaces en haut. Néanmoins, un devoir technique est capable de rattraper et de taper sur le capuchon, ce qui nous est arrivé approximativement.


Après 3 mois de travail sur le projet, j'ai finalement convenu avec mes collègues de passer à un seul dictionnaire pour que les propriétés de l'objet soient nommées et écrites de la même manière partout. Dans le cadre de cette entreprise, bien sûr, je me suis engagé à écrire une interface et je suis resté coincé avec elle pendant deux jours ouvrables.


Le problème


Un simple profil d'utilisateur sera un exemple abstrait.


  • D'abord L'étape zéro d'un bon développeur: décrire les données écrire des tests;
  • Première étape: écrire des tests Décrire les données
  • et ainsi de suite.

Supposons que des tests aient déjà été écrits pour ce code, il reste à décrire les données:


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

Eh bien, ici, tout est clair et extrêmement simple. Tout ce code, comme nous nous en souvenons, sur le backend, ou plutôt, dans l'API, c'est-à-dire que l'utilisateur est créé à partir de données provenant du réseau. Ainsi, nous devons valider les données entrantes et aider Joi dans ce domaine:


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

La solution "sur le front" est prête. L'inconvénient évident de cette approche est que le validateur est complètement séparé de l'interface. Si pendant la durée de vie de l'application les champs changent / ajoutent ou que leur type change, alors ce changement devra être suivi manuellement et indiqué dans le validateur. Je pense qu'il n'y aura pas de développeurs aussi responsables jusqu'à ce que quelque chose tombe. De plus, dans notre projet, le questionnaire comprend plus de 50 champs à trois niveaux d'imbrication et il est extrêmement difficile de le comprendre, même en sachant tout par cœur.


Nous ne pouvons tout simplement pas spécifier const joiUserValidator: IUser , car Joi utilise ses types de données, ce qui génère des erreurs lors de la compilation du type Type 'NumberSchema' is not assignable to type 'number' . Mais il doit y avoir un moyen d'effectuer une validation sur l'interface?


Peut-être que je ne l'ai pas recherché correctement sur Google, ou que j'ai mal étudié les réponses, mais toutes les décisions ont été prises pour extractTypes des types et des vélos féroces, comme ceci :


 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; 

Solution


Utiliser des bibliothèques tierces


Pourquoi pas. Lorsque j'ai interrogé les gens sur ma tâche, j'ai reçu dans l'une des réponses, et plus tard, et ici, dans les commentaires (grâce aux keenondrums ), des liens vers ces bibliothèques:
https://github.com/typestack/class-validator
https://github.com/typestack/class-transformer


Cependant, il y avait un intérêt à le comprendre vous-même, à mieux comprendre le travail de TS, et rien ne poussait à résoudre le problème momentanément.


Obtenez toutes les propriétés


Comme je n'avais aucun travail antérieur avec la statique, le code ci-dessus a découvert l'Amérique en termes d'utilisation d'opérateurs ternaires dans les types. Heureusement, il n'a pas été possible de l'appliquer dans le projet. Mais j'ai trouvé un autre vélo intéressant:


 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 dans des conditions plutôt délicates et mystérieuses vous permet d'obtenir, par exemple, des clés de l'interface, comme s'il s'agissait d'un objet JS normal, cependant, uniquement dans la construction de type et via key in keyof T et uniquement via des génériques. En raison du type UserKeys , tous les objets qui implémentent les interfaces doivent avoir le même ensemble de propriétés, mais les types de valeurs peuvent être arbitraires. Cela inclut des conseils dans l'IDE, mais ne permet toujours pas d'indiquer clairement les types de valeurs.


Voici un autre cas intéressant que je n'ai pas pu utiliser. Peut-être pouvez-vous me dire pourquoi cela est nécessaire (bien que je suppose en partie, il n'y a pas assez d'exemples appliqués):


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

Utiliser des types de variables


Vous pouvez définir explicitement les types, et en utilisant "OU" et en extrayant les propriétés, obtenir un code de travail local:


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

Le problème de ce code se manifeste lorsque nous voulons récupérer un objet valide, par exemple, dans la base de données, c'est-à-dire que TS ne sait pas à l'avance quel type de données sera - simple ou Joi. Cela peut provoquer une erreur lors de la tentative d'effectuer des opérations mathématiques sur un champ prévu comme 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 

Cette erreur provient de Joi.NumberSchema car l'âge peut être non seulement un number . Ce pour quoi ils se sont battus et ont rencontré.


Combinez deux solutions en une seule?


Quelque part à ce stade, la journée de travail approchait de sa conclusion logique. J'ai pris une grande inspiration, j'ai bu du café et j'ai effacé cette putain de baise. Il faut moins les lire sur Internet! Le temps est venu prendre un fusil de chasse et lavage de cerveau:


  1. Un objet doit être formé avec des types de valeurs explicites;
  2. Vous pouvez utiliser des génériques pour lancer des types dans une seule interface;
  3. Les génériques prennent en charge les types par défaut;
  4. La construction de type est clairement capable d'autre chose.

Nous écrivons l'interface générique avec des types par défaut:


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

Pour Joi, vous pouvez créer une seconde interface, héritant de la principale de cette manière:


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

Pas assez bon, car le prochain développeur peut étendre IUserJoi avec un cœur léger ou pire. Une option plus limitée consiste à obtenir un comportement similaire:


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

Nous essayons:


 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:
Pour terminer dans Joi.object j'ai dû me battre avec l'erreur TS2345 et la solution la plus simple était la as any . Je pense que ce n'est pas une hypothèse critique, car l'objet ci-dessus est toujours sur l'interface.


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

Il compile, semble soigné sur le lieu d'utilisation et, en l'absence de conditions spéciales, définit toujours les types par défaut! La beauté ...
-
... ce que j'ai passé deux jours ouvrables


Résumé


Quelles conclusions peut-on tirer de tout cela:


  1. Évidemment, je n'ai pas appris à trouver des réponses aux questions. Certes, avec une demande réussie, cette solution (ou mieux encore) se trouve dans les premiers 5k liens du moteur de recherche;
  2. Passer à la pensée statique de la dynamique n'est pas si facile, beaucoup plus souvent je martèle simplement un tel essaimage;
  3. Les génériques sont cool. Sur Habr et stackoverflow est plein de vélos des solutions non évidentes pour créer un typage fort ... en dehors du runtime.

Ce que nous avons gagné:


  1. Lors du changement d'interface, tout le code tombe, y compris le validateur;
  2. Dans l'éditeur, des conseils sont apparus sur les noms de propriété et les types de valeurs d'objet pour écrire un validateur;
  3. Le manque de bibliothèques tierces obscures dans le même but;
  4. Les règles Joi ne seront appliquées que lorsque cela est nécessaire, dans d'autres cas, les types par défaut;
  5. Si quelqu'un veut changer le type de valeur d'une propriété, alors avec la bonne organisation du code, il ira à l'endroit où tous les types associés à cette propriété sont rassemblés;
  6. Nous avons appris à masquer magnifiquement et simplement les génériques derrière l'abstraction de type , déchargeant visuellement le code des constructions monstrueuses.

Morale: l' expérience n'a pas de prix; pour le reste, il y a une carte du monde.


Vous pouvez voir, toucher, exécuter le résultat final:
https://repl.it/@Melodyn/Joi-by-interface

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


All Articles