- Quand des vérifications comme
if (user.email)sont disséminées dans du code TypeScript, le fait déjà vérifié ne reste pas dans le type, ce qui pousse à remettre en doute la même condition plus loin dans la pile d’appels - Un parseur prend une entrée brute et renvoie un type plus restreint ou des informations d’échec, ce qui permet au reste du programme de faire confiance à des faits validés, comme
EmailAddress - Dans TypeScript, qui utilise un système de types structurel,
stringetEmailne se distinguent pas naturellement ; on imite donc une frontière nominale avec des types brandés basés surunique symbolet des assertionsaslimitées - Une union discriminée comme
Parsed<T>expose le succès et l’échec dans la signature de type, mais faute d’expressionmatchdédiée, il faut écrire soi-même des vérifications exhaustives avecnever - Zod, io-ts et valibot permettent de créer à la fois un parseur et un type TypeScript à partir d’un schéma, mais la discipline consistant à parser à chaque frontière avant de considérer une entrée externe comme un type du domaine reste à la charge des développeurs
La validation jette l’information, le parsing la conserve dans le type
- Le principe Parse, don’t validate d’Alexis King met au centre la différence entre validateur et parseur
- Un validateur décide que « cette valeur est correcte », puis poursuit le flux avec un booléen ou une exception
- Un parseur prend une entrée brute et produit un type plus précis, ou renvoie la raison de l’échec
- Si les types restent larges, comme
User.email: stringouUser.age: number, TypeScript ne mémorise pas le fait queisValidUser(user): booleanest passé avec succès - Plus tard, dans du code comme
emailService.send(user.email, ...),user.emailreste une simple string, qui pourrait être une chaîne vide,"hello"ou"definitely not an email" - Le flux qui revérifie la même condition à plusieurs endroits ressemble à ce que King appelle le shotgun parsing
Des API où le type lui-même sert de preuve
- La forme souhaitée est une signature de fonction qui n’accepte que des valeurs parsées, comme
sendWelcome(user: ValidUser) - Avec cette structure, il faut obligatoirement passer par le parseur avant d’appeler
sendWelcome, et l’intérieur de la fonction n’a pas besoin de revalidation ni deifdéfensif supplémentaire - En Elm, cela se traite simplement avec un type opaque et un smart constructor, mais en TypeScript il faut davantage de mécanismes pour obtenir le même effet
Créer des frontières nominales avec des types brandés
- TypeScript utilise un système de types structurel : des types ayant la même forme sont traités comme le même type
- Une
stringest unestring, et il n’existe pas de fonctionnalité pour créer un vrai type distinct comme lenewtypede Haskell
- Une
- Le contournement utilisé par la communauté est le branding ou tagging
- Une approche simple consiste à ajouter un champ fantôme de type littéral chaîne, comme
{ readonly __brand: "Email" } - Une approche plus forte consiste à utiliser comme clé de brand un
unique symbolnon exporté hors du module
- Une approche simple consiste à ajouter un champ fantôme de type littéral chaîne, comme
- Les types d’exemple prennent la forme
type Email = string & { readonly [EmailBrand]: true },type Age = number & { readonly [AgeBrand]: true } - Le champ de brand est un marqueur au niveau du type qui n’existe pas à l’exécution, et il fait traiter
Emailetstringdifféremment à la compilation - Le brand ne fonctionne que dans un sens
Emailpeut être assigné àstring- Une
stringordinaire ne peut pas entrer directement dansEmail
Le parseur n’autorise les assertions qu’à la frontière de confiance
parseEmail(raw: string): Parsed<Email>renvoie un échec si la chaîne ne contient pas@, et crée un type brandé avecraw as Emailsi elle passe- L’assertion
as Emailest une exception autorisée parce que le parseur constitue la frontière de confiance- Si, ailleurs dans la base de code, on affirme qu’une
stringest unEmail, la conception s’effondre - On peut placer le parseur dans un module séparé et traiter comme un bug toute assertion de brand qui apparaît en dehors de ce module
- Si, ailleurs dans la base de code, on affirme qu’une
- Dans l’exemple,
Parsed<T>a la forme{ kind: "ok"; value: T } | { kind: "err"; error: ParseError }- L’échec n’est pas caché dans une exception, il apparaît dans la signature de type
- En utilisant un discriminateur chaîne comme
kind: "ok" | "err", le narrowing de types se comporte plus honnêtement lorsque d’autres variantes sont ajoutées par la suite
- L’exemple
parseEmailest volontairement minimal ; un vrai parseur d’e-mail devrait aussi gérer trim, lowercase, validation du domaine, etc.
Séparer les entrées brutes des types de domaine fiables
- En séparant
UnvalidatedUseretValidUser, on distingue clairement les valeurs issues du réseau ou d’entrées externes de celles auxquelles le domaine peut faire confianceUnvalidatedUsergardeid,emailetageenunknownValidUserutilise des types brandés commeUserId,EmailetAge
- Brander aussi
UserIdévite de passer par erreur un autre identifiant, commeOrderId, là où unUserIdest attendu parseUser(raw: unknown): Parsed<ValidUser>restreint progressivement l’entrée brute- Il vérifie que l’entrée est un objet
- Il vérifie la présence des champs
id,emailetage - Il vérifie que
emailest une chaîne - Il appelle respectivement
parseUserId,parseEmailetparseAge, et retourne immédiatement en cas d’échec - Si tout réussit, il renvoie un
ValidUser
- Cette approche est plus verbeuse qu’en F# ou Elm, mais
sendWelcome(user: ValidUser)devient réellement sûr
Les points où TypeScript gêne
- Le premier frottement est l’assertion
as Emailà l’intérieur du parseur- Dans un vrai langage à types nominaux, un smart constructor peut renvoyer un nouveau type sans mentir
- Le brand de TypeScript est un marqueur de type virtuel, donc le parseur doit passer par une assertion
- Le deuxième frottement est la vérification d’exhaustivité
- Les unions discriminées de TypeScript sont puissantes pour ce style, mais il n’existe pas d’expression
matchdédiée - Il faut écrire soi-même un motif comme
const _exhaustive: never = resultdans ledefaultd’unswitch - Si une troisième variante est ajoutée à
Parsed, l’assignation àneveréchoue et le compilateur indique l’endroit concerné
- Les unions discriminées de TypeScript sont puissantes pour ce style, mais il n’existe pas d’expression
satisfiespeut servir d’escape hatch plus polie qu’un castconst x = { ... } satisfies Configvérifie le type sans élargir inutilement les types littéraux
JSON.parserenvoieany, il est donc plus sûr de l’annoter immédiatement enunknown- On le reçoit sous la forme
const raw: unknown = JSON.parse(input), puis le parseur décide ensuite s’il s’agit d’un type de domaine JSON.parsen’est pas un validateur, mais une étape de désérialisation qui transforme des octets en valeur JS
- On le reçoit sous la forme
Les bibliothèques comme Zod réduisent la répétition
- Zod, io-ts et valibot fournissent le même pattern de façon plus pratique que des parseurs écrits à la main
- L’exemple Zod crée à la fois un parseur et un type TypeScript à partir d’un seul schéma
z.object({ id: z.number().int(), email: z.string().email().brand<"Email">(), age: z.number().int().min(0).max(150).brand<"Age">() })- On obtient le type avec
z.infer<typeof ValidUserSchema> ValidUserSchema.safeParse(rawInput)renvoiedataen cas de succès eterroren cas d’échec
- Le
.brand()de Zod est lui aussi une fonctionnalité au niveau du type, comme un brand symbol fait à la main, et n’a pas de comportement à l’exécution - Les bibliothèques regroupent parseur et type dans une même définition, ce qui facilite le respect des frontières, mais elles ne peuvent pas imposer à votre place la discipline de les utiliser à toutes les frontières externes
- Un
Uservenu du réseau n’est pas unUserdu domaine tant qu’il n’a pas été parsé, et il faut éviter la tentation de contourner les messages d’erreur par des assertions de type
Porter la preuve dans le type, pas dans la mémoire
- Le petit principe est le suivant : « faites porter la preuve par le système de types, ne la confiez pas à la mémoire humaine »
- Si vous vérifiez une condition sans encoder le résultat dans le type, le code qui suit supposera facilement que cette validation est déjà terminée
- En TypeScript, ce principe s’appuie sur trois outils
- des types brandés qui imitent une identité nominale
- des unions discriminées qui exposent le succès et l’échec
- une frontière stricte entre les entrées externes en
unknownet les types de domaine fiables
- Il n’est pas toujours approprié de transformer tout le code en pipeline de parsing, mais si les mêmes
ifdéfensifs se répètent dans plusieurs fichiers, c’est le signe que l’information à valider n’a pas été portée dans le type
1 commentaires
Avis sur Lobste.rs
Si le style de code que JavaScript/TypeScript favorise entre en conflit avec ce que l’on veut, aussi bien techniquement qu’en termes d’ergonomie, autant utiliser l’un des nombreux langages qui se compilent en JS, non ?
Haskell, Elm et F# sont mentionnés, et il existe beaucoup d’autres langages du même genre que l’auteur semble davantage vouloir utiliser, comme PureScript, js_of_ocaml, Reason ou LunarML. L’auteur a même écrit un article intitulé Why TypeScript Won’t Save You, où il compare davantage avec ses langages préférés, et gère aussi https://learnelm.dev.
Ou alors la comparaison est peut-être l’objectif en soi : montrer que TypeScript n’est pas suffisant dans beaucoup de cas, et encourager l’adoption d’autres toolchains ou idées.
La plupart des gens n’ont tout simplement pas la possibilité ni le temps de choisir un autre langage.
Au travail, j’aime beaucoup les types marqués (branded types), mais le fait de ne pas pouvoir créer un Array ou un TypedArray indexable uniquement avec des nombres marqués me dérange vraiment.
Un TypedArray ne permet même pas de stocker des nombres marqués, ou plus exactement d’en lire. Même s’il fallait un ensemble de types séparé, comme IndexArray ou IndexTypedArray, j’aimerais vraiment que cette fonctionnalité existe.
Avec un schéma de base de données assez complexe, si on utilise des types marqués pour tous les ID, TypeScript détecte les jointures ou conditions absurdes. Les signatures de fonctions deviennent aussi plus claires, et il devient plus difficile d’introduire diverses erreurs.
Si on le souhaite, on peut faire la même chose avec les valeurs d’un TypedArray.
TArray<Foo, MyEnum>. Mais là, il s’agit de C++.La bibliothèque
stdde Zig propose un EnumArray implémenté aveccomptime. Elle offre aussi des fonctionnalités plus larges, comme l’utilisation d’enums denses ou clairsemés pour l’indexation, et le calcul du bon indexeur au moment de la compilation.Ce genre de typage précis me plaît de plus en plus. Cela empêche dans une large mesure les bugs logiques d’entrer dans la base de code.