Typescript et Zod pour valider ses structures de données

Publié le

illustration de l'article

Depuis quelques années, Typescript est devenu un langage incontournable lorsque l’on veut produire du code Javascript de qualité. Au delà du langage qui ne cesse d’évoluer, c’est surtout le système de vérification statique de types (Static Type checking) qui permet de signaler au développeur toute erreur potentielle.

Cependant, cette vérification montre rapidement ses limites car elle n’est effectuée qu’à la compilation.

Aucune vérification n’est faite à l’éxécution du code (ie: runtime)…

Typescript ne vérifie pas les types au runtime

Définissons un type User qui possède deux propriétés name et age :

type User = { 
    name : string;
    age: number;
}

Définissons une simple fonction pour déterminer si un user est majeur ou non :

const isAdult = (user: User): boolean => {
    return user.age > 18
}

L’utilisation de ce type ne pose pas de problème dans un contexte de code maitrisé :

const john: User = {name: "John", age: 20};
console.log(isAdult(john)) // output: true

En utilisant “une assertion de type” , pas de soucis non plus :

const john= {name: "John", age: 20} as User
console.log(isAdult(john)) // output: true

Et si on change la structure en omettant une propriété ?

const john= {name: "John"} as User
console.log(isAdult(john)) // output: false,  what ?

Aucune erreur émise et pourtant le code est incorrect !

Plusieurs erreurs dans la fonction :

  • pas de vérification de l’existence de la propriété age
  • pas de vérification du type number avant la comparaison

En utilisant as User, on accepte implicitement que la structure des données ne soit pas celle définie dans le type.

Comment faire confiance aux saisies utilisateurs ou réponses API ?

Prenons un autre exemple où la donnée ne provient pas du code applicatif mais d’une source externe :

// age key is missing 
const json = `{"name": 2}`;
const data = JSON.parse(json) as User;

Ou encore :

async function getUser(id) : User => {
    // data received is not verified...
    let rep = await fetch(`http://localhost:8000/users/${id}`);
    // is it a User shape ?
    let user = await rep.json() as User;
    return user;
}

On peut donc continuer notre logique de code avec des données qui ne correspondent que partiellement, voire pas du tout, à notre type…

💡 Ici, on ne vérifie pas non plus les autres cas d’erreurs (ex: HTTP status code différent de 20x, réponse de type non JSON, etc).

Vérification manuelle des données

Pour éviter toute erreur à l’éxécution, on devrait rajouter des vérification sur les propriétés et leurs type.

On applique une approche défensive :

if (user?.age && typeof user.age === "number") {
    return user.age > 18
}
throw new Error("Invalid User provided") //or any other error management 

Ces vérifications peuvent vite s’avérer fastidieuses lorsqu’il y a plusieurs propriétés à valider… De plus, il est possible d’oublier des cas.

Vérification avec la librairie Zod

La validation d’un schéma permet de s’assurer que la structure des données reçues est bien conforme à la structure attendue.

Zod est une libairie open-source Typescript qui sert à déclarer et valider vos schémas (ie: structure de données)

Vous allez donc définir des “schéma” Zod qui vont servir à valider vos données.

Définition de schéma

Exemple de schéma Zod qui vérifie le type string et que le la longeur est comprise entre 1 (minimum) et 20 (maxixum) :

const StringSchema = z.string()
                        .min(1, { message: "Too short" })
                        .max(20, { message: "Too long" });

Zod permet de valider les types primitifs de Typescript

Il est également possible de chainer, combiner et étendre les schémas existants :

const UserSchema = z.object({
  email: z.string().email().min(5),
  name: z.string(),
  age: z.number().min(2).max(3).optional()
})

const IdSchema = z.object({
  id: z.string().uuid()
})
const UserWithId = UserSchema.merge(IdSchema)

Validation des données

Zod offre deux possiblités de gestion des erreurs en cas de donnée invalide

Une exception ZodError est envoyée sur la donnée est invalide

const stringSchema = z.string();

stringSchema.parse("fish"); // => returns "fish"
stringschéma.parse(12); // throws error, catch it !

Si l’on souhaite gérer le cas d’erreur sans exception, on peut utiliser safeParse et vérifier la valeur de la propriété success :

const result = stringschéma.safeParse(12); 
if (!result.success) {
  result.error.issues;
  /* [
      {
        "code": "invalid_type",
        "expected": "string",
        "received": "number",
        "path": [ "name" ],
        "message": "Expected string, received number"
      }
  ] */
}

Inférence de type depuis le schéma

Zod permet facilement de définir vos types à partir de la définition des schémas

Exemple avec un simple enum :

export const NobelCategorySchema = z.enum([
    "chemistry",
    "economics",
    "literature",
    "peace",
    "physics",
    "medicine",
]);
export type NobelCategory = z.infer<typeof NobelCategorySchema>;

Si l’ont inspect le type, cela se traduit en Typescript par :

type NobelCategory = "chemistry" | "economics" | "literature" | "peace" | "physics" | "medicine"

Un autre exemple avec un type plus complexe :

export const LaureateSchema = z.object({
    id: z.coerce.number(),
    firstname: z.string().default("🧐 John Doe ?"),
    surname: z.string().optional(),
    motivation: z.string(),
});
export type Laureate = z.infer<typeof LaureateSchema>;

Cela se traduit par :

type Laureate = {
   id: number;
   firstname: string;
   motivation: string;
   surname?: string | undefined;
}

On peut vérifier nos données grâce à la validation de schéma zod :

it("should not validate if invalid field", () => {
    const laureate = "invalid";
    const result = LaureateSchema.safeParse(laureate);

    expect(result.success).toStrictEqual(false);
});

it("should validate with optional fields", () => {
    const laureate = {
        id: "55",
        motivation: "To be known",
    };
    const result = LaureateSchema.safeParse(laureate);

    expect(result.success).toStrictEqual(true);
    if (result.success) {
        const model: Laureate = result.data;
        expect(model.id).toStrictEqual(55);
        expect(model.firstname).toStrictEqual("🧐 John Doe ?");
        expect(model.surname).toStrictEqual(undefined);
        expect(model.motivation).toStrictEqual("To be known");
    }
});

D’autres exemples de tests unitaires dans mon projet Zod + TS sur Github

Exemple pour une API

Dans le cas d’un appel API, on peut maintenant être sûr que les données retournées seront valides :

import { Prize, PrizeResponseSchema } from "./model";

export const getNobelPrizes = async (): Promise<Prize[]> => {
    const response = await fetch("https://api.nobelprize.org/v1/prize.json");
    // data received cannot be trusted at runtime
    const data: unknown = await response.json();
    // ensure data type Prize
    const result = PrizeResponseSchema.safeParse(data);
    if (!result.success) {
        throw Error("Error parsing prizes");
    } else {
        return result.data.prizes;
    }
};

On expose ainsi que le type Promiser<Prize[]>

Extrait de code issu de mon projet exemple TS + Zod sur Github

Zod + TS : la combinaison idéale ?

Typescript aide le développeur à s’assurer de la sûreté du typage (ie: type safety) lors de la compilation. Cependant, on ne peut pas faire confiance aux données que l’on ne maitrise pas..

La validation de structure (ie: schéma validation) s’avère indispensable lorsque l’on traite avec des données externes.

Les points forts de Zod :

  • librairie petit et sans dépendances externes, il peut être intégré facilement à vos projets Typescript
  • documentation très complète et Developer-friendly
  • inférence de type depuis le schéma
  • pensé pour une approche fonctionnelle (ex: optional() renvoie une nouvelle instance, pas l’objet muté.. )

Pour aller plus loin

Zod est utilisé dans la librairie tRPC qui permet de créer des API “Type Safe”. Il existe de nombreux exemples d’utilisation avec les différentes API phares (ex: Next, Express, etc).

Une librairie à regarder lors de la création de votre prochaine server back NodeJS !

#typescript #javascript #zod

D'autres articles à lire