Journal Vérifiez vos types avec TypeScript et io-ts

Posté par  .
Étiquettes : aucune
16
11
sept.
2018

Sommaire

TL,DR : avec TypeScript et io-ts, on peut passer d'un programme JavaScript court et correct, mais fragile et difficile à maintenir, à un programme facile à refactorer et robuste contre les modifications externes inattendues, rien qu'en ajoutant une définition de type et un if.

TL,DR bis : vous pouvez regarder le premier et le dernier exemple de code du journal pour vous faire une idée.

JavaScript c'est quoi ?

JavaScript est un langage au typage dynamique et faible.

Pour décrire la chose simplement (mais au risque de faire hurler les puristes), cela veut dire :

  • Typage dynamique : les types ne sont pas déclarés dans le code source (en JavaScript, on utilise des mots-clé comme "var" ou "let" et non des types comme "int")
  • Typage faible : il y a peu de vérifications des erreurs de type et il est possible de faire des cast implicites (en JavaScript on peut passer n'importe quel type à une fonction, et si le type n'est pas adapté on aura le plus souvent une erreur logique qu'une erreur de type)

Ces spécificités sont assez connues même en dehors des utilisateurs, grâce à un florilège d'exemples cocasses qui sont souvent la sources de moqueries sur le langage (personnellement, je trouve que ce n'est pas la faute du langage : si on saisit des expressions débiles, il ne faut pas être surpris d'avoir un résultat débile).

Cela dit, ces spécificités sont très pratiques pour faire du code concis et réutilisable.

Par exemple, admettons que l'ont ait une application qui utilise une API météo, on peut vouloir :

  • faire un appel à l'API en HTTP et traiter le résultat
  • utiliser un résultat de l'API qu'on a stocké de la base de donnée et le traiter
  • traiter un "résultat" écrit à la main (par exemple pour faire un test unitaire).

Pour faire plus concret, disons que l'API retourne un relevé sous forme de JSON de la sorte :

{
    "city":"Paris",
    "time":"2018-09-11T10:53:21Z",
    "temperature":14
}

Ensuite, on veut traiter le relevé via une fonction "traiteRelevé".

Dans un langage au typage fort et statique, on aurait besoin d'un objet ou d'une structure "relevé", et de convertir les trois sources (json de l'API, enregistrement de la base de données, expression dans le code) vers ce type, probablement de trois façons différentes.

En JavaScript, pas besoin de tout ça :

// 1) avec l'API
const fetch_res = await fetch("https://example.com/api/releve/Paris")
const releve1 = await fetch_res.json()

// 2) avec la base de données
const postgres_res = await client.query("select city,time,temperature from data where city='Paris'")
const releve2 = postgres_res.rows[0]

// 3) en dur
const releve3 = { city:"Paris", time:"2018-09-11T10:53:21Z", temperature:14 }

traiteReleve(releve1)
traiteReleve(releve2)
traiteReleve(releve3)

Les trois objets releve peuvent être utilisés interchangeablement, bien qu'ils viennent de sources différentes, et, si l'on peut faire confiance à la base de données et à l'API, on n'a même pas de code de vérification d'erreur supplémentaire à écrire.

Oui mais pourquoi tu nous parles de TypeScript et d'io-ts si JavaScript c'est aussi bien ?

Le code qu'on vient de voir est concis, marche bien (je suis modeste), mais il a un gros souci : il est très fragile et difficile à maintenir.

Parmi plein de choses, les choses suivantes peuvent arriver :

  • Même si on fait confiance à l'API (d'un point de vue sécurité), le type de données retournées peut être modifié
  • On peut modifier la requête sql, ou les types dans la base de données
  • On peut décider que c'est plus pratique, dans les objets relevé, d'avoir le champ "time" sous forme de Date et non de chaine de caractères.
  • On peut appeler la fonction relevé avec quelque chose qui n'est pas un relevé tel qu'on l'a prévu.

Pour tous ces problèmes on aura une erreur incompréhensible à l'exécution, voire, encore pire, pas d'erreur et un résultat faux à l'exécution.

Par exemple, supposons que la fonction traiteReleve retourne une chaine de caractères de type "14°C" et que le champ "temperature" est renommé en "temp".

// Cas 1 : erreur incompréhensible
const traiteReleve = (releve) => releve.temperature.concat("°C")
traiteReleve(releveAvecTemp)

// -> TypeError: undefined is not an object

// Cas 2 : mauvais résultat
const traiteReleve = (releve) => releve.temperature + "°C"
traiteReleve(releveAvecTemp)

// -> retourne la chaine "undefined°C"

Dans le 1er cas, avec un peu de chance on retrouvera l'erreur dans les logs, mais dans le 2e, encore moins de chance de voir le problème.

TypeScript à la rescousse

TypeScript est un système de types fort et statique, mais qui est conçu exprès pour JavaScript. Il sert uniquement à l'analyse statique (ie. vérifier le code et trouver les erreurs au moment de la compilation). Il a deux gros avantages :

  • il est conçu de façon a respecter "l'esprit" de JavaScript et permet de faire des patterns très dynamiques tels ceux qu'on a vu plus haut
  • il utilise ce qu'on appelle l'inférence de types : il déduit les types du contexte, et donc il y a très peu de types à déclarer explicitement.

D'ailleurs, si on reprend le code du début du journal, la version TypeScript est identique à la version JavaScript. Le revoilà avec en commentaire les types que TypeScript a trouvé :

// 1) avec l'API
const fetch_res = await fetch("https://example.com/api/releve/Paris") // type: Response
const releve1 = await fetch_res.json() // type: any

// 2) avec la base de données
const postgres_res = await client.query("select city,time,temperature from data where city='Paris'") // type: QueryResult
const releve2 = postgres_res.rows[0] // type: any

// 3) en dur
const releve3 = { city:"Paris", time:"2018-09-11T10:53:21Z", temperature:14 } // type: {city: string, time: string, temperature: number}

traiteReleve(releve1)
traiteReleve(releve2)
traiteReleve(releve3)

Par ailleurs, la définition de traiteRelevé est différente en TypeScript :

// En JS :
function traiteReleve(releve) {}
// En TypeScript :
function traiteReleve(releve: {city: string, time: string, temperature: number}) {}

Regardons de plus près ce que fait TypeScript pour notre code

Qu'est-ce que TypeScript a fait ?

Pour les cas 1) et 2), TypeScript a affecté le résultat de la requête HTTP et de la requête SQL au type any (logique, au moment de la compilation tout ce qu'on sait c'est qu'une requête http ou sql peut retourner n'importe quoi). Par contre, pour le cas 3), il a deviné tout seul le type de notre constante releve3.

Après, on appelle la fonction traiteReleve, dont le type du paramètre a été défini. Qu'est-ce qui va se passer ?

Cas 3

Pour le cas 3, TypeScript constate que le type attendu par la fonction est le même que celui de releve3, il est content.

Maintenant admettons qu'on modifie releve3 ainsi :

const releve3 = { city:"Paris", time:new Date("2018-09-11T10:53:21Z"), temperature:14 } // type: {city: string, time: string, temperature: number}

TypeScript va constater tout seul que time est de type Date dans releve3, mais que la fonction traiteReleve attend un time de type string: il y aura une erreur au moment de la compilation.

Dans un vrai programme, il y aura des erreurs à tous les endroits problématiques, ce qui rend la refactorisation très facile.

Cas 1 et 2

Dans les cas 1 et 2, TypeScript ne peut pas deviner le type des variables releve1 et releve2.

Les relevés sont de type "any". Le type "any" signifie que la variable de ce type est assignable à des variables de n'importe quel type. Ca signifie qu'il n'y a pas de vérification sur les types ce qui est assez nul.

Avec TypeScript 3.0, un nouveau type a été ajouté: "unknown". Ce type est le contraire de "any" : il signifie que la variable de type "unknown" est assignable à à peu près rien. Cela veut dire qu'on est obligé de faire une vérification ou un cast explicite pour continuer. Espérons que les types des librairies seront réécrits pour utiliser ce nouveau type.

Cela dit, dans les cas 1 et 2 et qu'on utilise unknown ou any, TypeScript ne peut pas nous aider : le type des variables retournés par les appels HTTP et à la base de données ne sont connus qu'à l'exécution.

On a deux solutions :

  • partir de l'hypothèse que la base de données et l'API retournent toujours les bons types et faire un cast
  • vérifier les types à l'exécution

On a dit au début du journal qu'on voulait rendre notre code moins fragile, donc c'est sur la vérification des types à l'exécution qu'on va partir.

La vérification des types à l'exécution (en général, pas que en JavaScript)

Quand on écrit un programme, l'idéal est de vérifier un maximum de choses à la compilation : on sait tout de suite qu'il y a une erreur, on n'a pas besoin de tester toutes les branches possibles pour la trouver.

Cependant, on ne peut pas tout vérifier à la compilation. Déjà, c'est impossible : l'analyse statique d'un programme est un problème indécidable. Mais surtout, pour un "vrai programme" on a des données qui viennent de l'extérieur qu'on ne contrôle pas.

La solution habituellement retenue, est d'utiliser un typage statique à l'intérieur du programme (donc de vérifier à la compilation), et de s'assurer aux entrées du programme que les données passées sont bien du type attendu (donc de vérifier à l'exécution)

La vérification des types à l'exécution (pour notre exemple)

On a dit au début du journal que l'avantage de JavaScript était que le code était court et concis, et que pour notre exemple il n'y a pas besoin de faire de vérifications. Mais maintenant on veut en faire. C'est contradictoire.

C'est là que la librairie io-ts vient à la rescousse. Elle permet de faire des vérifications à l'exécution de façon extrêmement simple et concise. Regardons comment l'utiliser.

io-ts

Dans notre exemple, on veut s'assurer que ce qui est retourné par l'API ou HTTP ressemble bien à un referer.

Pour cela on va définir un "type" io-ts :

const Releve = t.type({
  city: t.string,
  time: t.string,
  temperature: t.number
})

Si vous connaissez JavaScript, vous vous direz "hmm, c'est probablement un objet, pas un type". Effectivement, c'est un objet, et voyons maintenant comment l'utiliser :

const Releve = t.type({
  city: t.string,
  time: t.string,
  temperature: t.number
})

const releve: unknown = await fetch_res.json() // on caste releve à unknown pour éviter qu'il soit utilisé sans qu'on fasse de vérification

// releve est de type unknown

if (Releve.is(releve)) {
    // releve est désormais de type {city: string, time: string, temperature: number}
    traiteReleve(releve)
} else {
    // et là releve est de nouveau de type unknown
    throw "Erreur"
}

On n'a fait qu'ajouter un "type" et un if, mais il s'est passé plein de choses :

  • statiquement (au moment de la compilation) :
    1. Grâce à l'inférence de types, TypeScript sait que Releve est d'un certain type, défini à partir des types de t.type, t.string, etc. (t.* sont des fonctions et propriétés d'io-ts).
    2. Toujours grâce à l'inférence de types, TypeScript sait Releve.is est une fonction de type typeguard (elle vérifie que son argument est d'un certain type).
    3. Enfin, TypeScript déduit que releve est de type {city: string, time: string, temperature: number} dans la branche principale du if mais de type unknown dans l'autre.
  • dynamiquement (au moment de l'exécution) : la fonction Releve.is est exécutée et vérifie si releve est du type attendu

En résumé, en ajoutant quelques lignes qui ne sont pas plus longues qu'une déclaration de struct en C ou d'object en Java :
- On a une erreur à la compilation si on utilise la variable releve obtenue à partir de l'API sans l'avoir vérifiée
- On a à l'exécution une fonction qui vérifie le type de l'objet obtenu à partir de l'API

En conclusion :

Voilà le programme du début complété avec tout ce qu'on a vu :

const Releve = t.type({
  city: t.string,
  time: t.string,
  temperature: t.number
})

// 1) avec l'API
const fetch_res = await fetch("https://example.com/api/releve/Paris")
const releve1:unknown = await fetch_res.json()

// 2) avec la base de données
const postgres_res = await client.query("select city,time,temperature from data where city='Paris'")
const releve2:unknown = postgres_res.rows[0]

// 3) en dur
const releve3 = { city:"Paris", time:"2018-09-11T10:53:21Z", temperature:14 }

if (Releve.is(releve1) && Releve.is(releve2)) {
    traiteReleve(releve1)
    traiteReleve(releve2)
    traiteReleve(releve3)
}
else
    gererLErreur()

Avec un if et une "déclaration de type" en plus, on a une gestion complète des types, que ce soit à la compilation ou à l'exécution, et on n'aura pas de surprise, ni quand on modifiera notre programme, ni quand les services externes (bdd, api) sont modifié.

  • # Remarque

    Posté par  (site web personnel) . Évalué à 2. Dernière modification le 11 septembre 2018 à 17:27.

    Merci pour cet article, je suis moi même un ardent défenseur du TypeScript.

    Il y a quelques imprécisions dans ta description du langage, mais je vais passer outre, c'est très bien pour une introduction.

    Cependant, ça serait mieux si ton exemple de code contenait le fichier entier, c'est à dire avec les import qui vont bien!

    Et avec io.type, je vois que du coup tu ne prends pas le temps de définir une interface pour la structure de données attendue, c'est volontaire ?

    • [^] # Re: Remarque

      Posté par  . Évalué à 1.

      Cependant, ça serait mieux si ton exemple de code contenait le fichier entier, c'est à dire avec les import qui vont bien!

      Tu as raison, ça aurait en plus permis de voir que je ne cache pas de boilerplate ou de complexité en ne mettant qu'un bout de fichier.

      Et avec io.type, je vois que du coup tu ne prends pas le temps de définir une interface pour la structure de données attendue, c'est volontaire ?

      Oui, c'est volontaire, le type se définit "tout seul" vu la façon dont io-ts est lui-même déclaré.

      C'est possible de définir une interface sans tout retaper en faisant comme ça :

      interface IReleve extends t.TypeOf<typeof Releve> {}

      Et évidemment si tu utilises VSCode ou autres tu vois tout le détail des types.

      • [^] # Re: Remarque

        Posté par  (site web personnel) . Évalué à 1. Dernière modification le 12 septembre 2018 à 14:28.

        Ok je vois, merci pour les précisions.

        Et évidemment si tu utilises VSCode ou autres tu vois tout le détail des types.

        Ahah, VSCode n'est pas le seul éditeur qui fourni ça, en fait moi j'utilise un IDE plutôt, Eclipse en l'occurence. Avec le plugin TypeScript.java, il parle avec le language server (tserver) tout comme le fait VSC, et j'ai tout ça aussi.

  • # Faudrait que je m'y mette…

    Posté par  (site web personnel) . Évalué à 2.

    Ça fait un certain temps que j'entends parler de TypeScript, mais je n'ai jamais pris le temps d'essayer. Même si je fais peu de JavaScript, je me dis que toute chose permettant d'avoir davantage de garanties sur mon code est bonne à prendre.
    Accessoirement, ça n'a pas l'air d'être très dur de s'y mettre, surtout si tout code JS valide est un code TS valide.

  • # Pourquoi JS alors ?

    Posté par  . Évalué à 2. Dernière modification le 16 septembre 2018 à 10:23.

    C'est une vrai question. Pourquoi utilisez du pseudo JS si le design du langage de base ne plaît pas ?

    • [^] # Re: Pourquoi JS alors ?

      Posté par  . Évalué à 3.

      JS est le seul langage qui s’exécute dans le navigateur. À l'époque de Dart 1 on pensait que dart pouvait être ajouté dans les navigateurs. Aujourd'hui on voit beaucoup de monde utiliser des surcouche plus ou moins épaisses au dessus de JS et compiler vers JS (https://github.com/jashkenas/coffeescript/wiki/list-of-languages-that-compile-to-js).

      JS devient alors le C du web. Toute bibliothèque JS peut être utilisée dans ces langages (généralement). Une fois que la chaine de build est mise en place, ça ne pose pas beaucoup plus de problème que ça de faire une compilation vers JS plutôt que vers de l'asm ou un autre bytecode.

Suivre le flux des commentaires

Note : les commentaires appartiennent à celles et ceux qui les ont postés. Nous n’en sommes pas responsables.