1 points par GN⁺ 4 시간 전 | 1 commentaires | Partager sur WhatsApp
  • 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, string et Email ne se distinguent pas naturellement ; on imite donc une frontière nominale avec des types brandés basés sur unique symbol et des assertions as limitées
  • Une union discriminée comme Parsed<T> expose le succès et l’échec dans la signature de type, mais faute d’expression match dédiée, il faut écrire soi-même des vérifications exhaustives avec never
  • 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: string ou User.age: number, TypeScript ne mémorise pas le fait que isValidUser(user): boolean est passé avec succès
  • Plus tard, dans du code comme emailService.send(user.email, ...), user.email reste 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 de if dé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 string est une string, et il n’existe pas de fonctionnalité pour créer un vrai type distinct comme le newtype de Haskell
  • 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 symbol non exporté hors du module
  • 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 Email et string différemment à la compilation
  • Le brand ne fonctionne que dans un sens
    • Email peut être assigné à string
    • Une string ordinaire ne peut pas entrer directement dans Email

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é avec raw as Email si elle passe
  • L’assertion as Email est 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 string est un Email, 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
  • 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 parseEmail est 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 UnvalidatedUser et ValidUser, on distingue clairement les valeurs issues du réseau ou d’entrées externes de celles auxquelles le domaine peut faire confiance
    • UnvalidatedUser garde id, email et age en unknown
    • ValidUser utilise des types brandés comme UserId, Email et Age
  • Brander aussi UserId évite de passer par erreur un autre identifiant, comme OrderId, là où un UserId est 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, email et age
    • Il vérifie que email est une chaîne
    • Il appelle respectivement parseUserId, parseEmail et parseAge, 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 match dédiée
    • Il faut écrire soi-même un motif comme const _exhaustive: never = result dans le default d’un switch
    • Si une troisième variante est ajoutée à Parsed, l’assignation à never échoue et le compilateur indique l’endroit concerné
  • satisfies peut servir d’escape hatch plus polie qu’un cast
    • const x = { ... } satisfies Config vérifie le type sans élargir inutilement les types littéraux
  • JSON.parse renvoie any, il est donc plus sûr de l’annoter immédiatement en unknown
    • 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.parse n’est pas un validateur, mais une étape de désérialisation qui transforme des octets en valeur JS

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) renvoie data en cas de succès et error en 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 User venu du réseau n’est pas un User du 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 unknown et 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 if dé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

 
GN⁺ 4 시간 전
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.

    • Il y a des contraintes comme les bases de code existantes, l’expertise de l’équipe dans un langage donné ou les consignes de l’entreprise, ainsi qu’un support, des outils et une communauté plus limités.
      La plupart des gens n’ont tout simplement pas la possibilité ni le temps de choisir un autre langage.
    • En général, j’imagine que c’est parce qu’il existe une grosse base de code TypeScript, ou parce qu’on utilise une bibliothèque TypeScript qui n’a pas d’équivalent dans 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.

    • Moi aussi, j’aime les types marqués, mais quand j’en parle, les gens trouvent en général que le bénéfice ne vaut pas vraiment l’effort.
      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 l’on est prêt à mentir assez fort, on peut créer un Array indexable uniquement par des nombres marqués.
      Si on le souhaite, on peut faire la même chose avec les valeurs d’un TypedArray.
    • Au travail, on utilise des « enums intelligents » et des types de tableaux personnalisés, ce qui permet d’écrire quelque chose comme TArray<Foo, MyEnum>. Mais là, il s’agit de C++.
      La bibliothèque std de Zig propose un EnumArray implémenté avec comptime. 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.