17 points par GN⁺ 2025-07-26 | 8 commentaires | Partager sur WhatsApp
  • En programmation, le système de types permet de distinguer clairement des données de sens différent
  • Utiliser tels quels des types génériques comme les chaînes ou les entiers fait perdre le contexte et peut mener à des bugs
  • Même avec le même type sous-jacent, définir de nouveaux types selon leur usage permet d’éviter les erreurs dès la compilation
  • Dans la bibliothèque Go libwx, des types distincts pour les unités de mesure sont définis afin d’éviter les erreurs liées au mélange de float64
  • Dans l’exemple de code, le type UUID est séparé en UserID et AccountID, ce qui permet au compilateur de bloquer les mauvais usages
  • Même dans des langages où le système de types est moins strict, comme Go, un simple encapsulage de types peut prévenir des bugs

Tirer pleinement parti du système de types

Point de départ du problème : le mélange de types simples

  • En programmation, on représente souvent de nombreuses valeurs uniquement avec des types de base comme string, int ou UUID
  • Mais à mesure qu’un projet grandit, les erreurs dues à l’utilisation interchangeable de ces types sans distinction deviennent fréquentes
    • Exemple : passer par erreur une chaîne userID comme accountID, ou inverser l’ordre de trois arguments de type int dans une fonction

La solution : définir des types qui expriment l’intention

  • int et string ne sont que des briques de base ; si on les fait circuler tels quels dans tout le système, le contexte sémantique disparaît
  • Pour éviter cela, il faut définir des types propres à chaque rôle et les utiliser partout
    • Exemple :
      type AccountID uuid.UUID  
      type UserID uuid.UUID  
      
      func UUIDTypeMixup() {  
          {  
              userID := UserID(uuid.New())  
              DeleteUser(userID)  
              // pas d'erreur  
          }  
      
          {  
              accountID := AccountID(uuid.New())  
              DeleteUser(accountID)  
              // erreur : impossible d'utiliser le type AccountID comme UserID  
          }  
      
          {  
              accountID := uuid.New()  
              DeleteUserUntyped(accountID)  
              // pas d'erreur à la compilation, forte probabilité de problème à l'exécution  
          }  
      }  
      
  • De cette manière, les arguments du mauvais type sont bloqués dès la compilation

Cas d’application concret : la bibliothèque libwx

  • L’auteur applique cette technique dans sa bibliothèque Go libwx
  • Pour chaque unité de mesure, il définit un type dédié, et les méthodes de conversion d’unité sont elles aussi rattachées au type
    • Exemple : la méthode Km.Miles() permet de distinguer clairement les unités
  • Voici un exemple où le compilateur bloque à la fois une inversion d’arguments et une confusion d’unités :
    // déclaration d'une température en Fahrenheit  
    temp := libwx.TempF(84)  
    
    // déclaration de l'humidité relative (pourcentage)  
    humidity := libwx.RelHumidity(67)  
    
    // transmis par erreur à une fonction qui attend une température en Celsius, pas en Fahrenheit  
    fmt.Printf("Dew point: %.1fºF\n",  
      libwx.DewPointC(temp, humidity))  
    // le compilateur détecte immédiatement l'erreur de type mismatch  
    // temp (type TempF) ne peut pas être utilisé comme TempC  
    
    // ordre des arguments incorrect dans l'appel de fonction  
    fmt.Printf("Dew point: %.1fºF\n",  
      libwx.DewPointF(humidity, temp))  
    // le compilateur empêche l'erreur de type sur les arguments  
    
  • Cela permet d’éviter toutes les erreurs qui pourraient survenir si l’on utilisait simplement des float64

Conclusion : exploiter activement le système de types

  • Le système de types n’est pas seulement un outil de validation syntaxique, c’est aussi un outil de prévention des bugs
  • Il faut définir un type d’ID distinct pour chaque modèle et encapsuler les arguments de fonction dans des types explicites plutôt que d’utiliser directement float ou int
  • Cette approche est très efficace et simple à mettre en œuvre, même dans des langages au système de types moins strict comme Go
  • En pratique, les bugs causés par le mélange de UUID ou de chaînes sont vraiment très fréquents
  • L’auteur souligne qu’il est surprenant qu’une méthode aussi simple soit encore si peu utilisée dans le code de production

Code associé

8 commentaires

 
vk8520 2025-07-29

À ma connaissance, en Kotlin, si on essaie de l’utiliser, les types primitifs sont enveloppés dans des wrappers, ce qui peut entraîner un problème de performances, car ils sont alors stockés sur le heap plutôt que sur la stack. Bien sûr, dans la plupart des cas d’usage, la maintenabilité reste prioritaire. En outre, on peut minimiser les problèmes de performances en utilisant les value classes.

 
regentag 2025-07-28

Le langage Ada dispose à cet égard d’un excellent système de types. On peut facilement déclarer les valeurs de nature différente comme des types distincts, et lorsque ces types sont mélangés, le compilateur les détecte efficacement.

 
roxie 2025-07-28

Par curiosité, je vous pose la question. Y a-t-il aussi des avantages qui la distinguent d'autres langages typés populaires ? (kotlin, rust, typescript, ...)

 
regentag 2025-07-28

L’avantage d’Ada est globalement du côté du « c’est mieux que C ». En C, on laisse beaucoup de choses à la discrétion du développeur, avec peu de garde-fous sur ce qui est autorisé. Les conversions de type implicites, par exemple. Mais la plupart des développeurs semblent quand même préférer C, sans doute parce qu’ils y sont habitués...

C’est peut-être une particularité de la base de code sur laquelle je travaille, mais nous déclarons et utilisons presque tout avec des types distincts. Le seul cas où nous utilisons des types de base, c’est à peu près pour les indices de tableau.

 
roxie 2025-07-28

J’ai compris, merci.

 
GN⁺ 2025-07-26
Commentaires Hacker News
  • J’aime cette approche, celle qui consiste à « rendre les mauvais états impossibles à représenter », mais le problème fréquent de ce pattern, c’est que les développeurs s’arrêtent souvent à la première étape de l’implémentation par les types : tout devient un type, ils ne s’assemblent pas bien entre eux, et on se retrouve avec une multitude de types légèrement modifiés, ce qui rend le code difficile à suivre et à comprendre ; dans une telle situation, je préférerais presque écrire dans un langage dynamique faiblement typé (JS) ou dynamiquement fortement typé (Elixir). En revanche, si les développeurs continuent à pousser la logique guidée par les types — en déplaçant la logique conditionnelle dans des unions discriminées compatibles avec le pattern matching, en utilisant bien la délégation, etc. — l’expérience de développement redevient agréable. Par exemple, une fonction DewPoint peut être conçue pour accepter plusieurs types tout en restant naturelle à utiliser.

    • C’est pour cette raison que j’aimerais que davantage de langages prennent en charge nativement les types bornés (limités à une plage d’entiers). Par exemple, au lieu de x: u32, j’aimerais pouvoir faire en sorte que x n’accepte que l’intervalle [0,10) et que cela soit imposé par le système de types. On n’aurait alors plus besoin de vérifications de bornes pour l’indexation de tableaux. Pour des cas comme Option, les optimisations peephole deviendraient aussi bien plus simples. En Rust, LLVM offre déjà partiellement ce genre de support à l’intérieur d’une fonction, mais pas lors du passage de variables entre fonctions.

    • À noter que Ruby n’est pas faiblement typé, mais fortement typé. Si on fait une opération comme 1 + "1", on obtient une erreur du type TypeError: String can't be coerced into Integer.

    • « S’arrêter à la première étape de l’implémentation par les types », c’est précisément la cause de l’échec. Par exemple, commencer à utiliser un int encapsulé dans une struct comme UUID est un bon départ, mais si quelqu’un peut simplement envelopper n’importe quel int et le transmettre, alors la propriété d’unicité attendue d’un UUID est déjà rompue. Au final, ce qui compte, c’est le principe de « correct by construction ». Un type qui doit être unique, comme un UUID, ne devrait pas pouvoir être créé tant que cette propriété n’a pas été réellement prouvée d’une manière ou d’une autre, que ce soit via une fonction, un constructeur qui lève une exception, ou un autre mécanisme. Cette idée s’applique non seulement aux UUID, mais à n’importe quel type et invariant.

    • Ces derniers temps, je suis le pattern Red-Green-Refactor, mais au lieu d’écrire un test qui échoue, je rends d’abord le système de types plus strict pour que les bugs soient attrapés par le vérificateur de types. Les nouvelles fonctionnalités, les edge cases ou les bugs qui ne peuvent pas être induits par les types restent couverts par des tests, mais une variante de red-green-refactor exploitant le système de types est en général plus rapide et permet d’éliminer complètement de grandes classes de bugs.

    • Les types structurels permettent d’atténuer la plupart de ces problèmes. Et quand c’est vraiment nécessaire, on peut imposer des types nominaux.

  • Dans le prolongement du sujet des exceptions et des types, je pense qu’il est bon de bien exploiter les exceptions checked afin de les traiter de manière adaptée selon leur type. Je ne comprends pas pourquoi les exceptions checked de Java sont autant critiquées. Dans un projet dont j’étais responsable, j’avais imposé leur usage : tout le monde a détesté au début, mais une fois habitués au fait de devoir réfléchir à tous les cas d’exception dans le flux du code, tout le monde a fini par apprécier. On était moins stricts sur les tests unitaires, mais le projet est devenu extrêmement robuste.

    • Les critiques envers les exceptions checked en Java viennent du fait que leur gestion est trop pénible. Les auteurs de bibliothèques ne peuvent pas toujours déterminer clairement quelles exceptions checked utiliser, et côté client, devoir gérer inutilement des exceptions à chaque appel de fonction devient vite agaçant. Si l’on pouvait facilement les convertir en d’autres types d’exception ou en exceptions runtime, ou simplement les déclarer au niveau d’un module ou d’une application, ce problème serait moindre, mais en pratique c’est trop lourd. En plus, comme elles cassent facilement les signatures, on est poussé à utiliser des exceptions propres au domaine, mais Java rend aussi la conversion d’exceptions peu ergonomique. Les exceptions checked sont une bonne idée, mais je n’aime pas l’ergonomie de la gestion des exceptions en Java.

    • Si les exceptions checked ont été critiquées, c’est surtout à cause de leur abus. Le fait que Java prenne en charge à la fois les exceptions checked et unchecked est un bon choix. Mais il vaut mieux réserver les exceptions checked à des cas comme les exceptions « exogenous » décrites par Eric Lippert, et convertir la plupart des autres en unchecked. Par exemple, une base de données peut toujours perdre sa connexion, mais faire remonter throws SQLException tout en haut de la pile d’appels est beaucoup trop pénible. Il suffit de faire un catch-all au niveau supérieur et de renvoyer une HTTP 500. Article lié

    • Les exceptions checked (par rapport aux unchecked) ont aussi le défaut suivant : si une fonction profonde dans la pile d’appels se met à lever une exception, il faut parfois modifier non seulement la fonction qui la traite, mais aussi toutes les fonctions intermédiaires. Cela réduit la flexibilité lors des changements du système. La controverse sur le coloring des fonctions async relève d’une logique similaire : si une fonction peut lever une exception, il faut soit l’entourer d’un try/catch, soit déclarer que l’appelant peut lui aussi lever une exception.

    • C# a des types clairs, mais a adopté les exceptions unchecked. Les piles d’erreurs restent propres et cela ne pose pas de problème. C’est plus propre que des gestionnaires d’exceptions spécialisés par niveau avec du traitement bespoke à chaque étage. S’il existe des résultats d’erreur unwrapables robustes, je pense que c’est comparable.

    • En Java, il y a aussi un vrai problème d’ergonomie avec les types checked. Par exemple, si l’on utilise l’API Stream et qu’une fonction map ou filter lève une exception checked, cela devient vraiment pénible. Si plusieurs appels de services lèvent chacun leur propre exception checked, on finit soit par attraper Exception, soit par écrire une liste d’exceptions absurdement longue.

  • Globalement, je suis d’accord avec l’idée de « créer des types distincts », mais j’ai souvent eu du mal dans des systèmes où tout était un type distinct, surtout quand du code qui ne fait que déplacer des octets se retrouve mélangé à du code de calcul métier.

    • Je comprends totalement cette impression. On a déjà les données nécessaires, mais il faut d’abord découvrir comment créer le type ou l’instance avant de pouvoir avancer ; sans recette prête à l’emploi, on a l’impression de se battre avec la documentation. Par exemple, on a un objet {x, y, z}, mais il faut passer par une fonction createVector(x, y, z): Vector, et pour créer un Face, il faut ensuite quelque chose comme createFace(vertices: Vector[]): Face, ce qui allonge inutilement la procédure. Avec quelque chose comme BouncyCastle, même si on a déjà un tableau d’octets prêt, il faut encore fabriquer plusieurs types et utiliser leurs méthodes respectives avant d’accéder à la fonctionnalité réellement voulue.

    • En Go, il est assez facile de revenir d’un alias de type à son type d’origine (par ex. AccountID → int). Si l’architecture est bien pensée, on peut écrire la logique métier avec des alias de type, et laisser les bibliothèques qui n’ont pas à connaître le domaine travailler via conversion vers des types plus hauts ou plus bas, dans un style clean architecture. Mais cela demande énormément de code de conversion.

    • Les phantom types sont utiles dans ce genre de cas. On ajoute un paramètre de type, donc un générique, mais ce paramètre n’est utilisé nulle part en pratique. J’ai déjà écrit du code de chiffrement en Scala où tout n’était au fond que des tableaux d’octets, mais les phantom types empêchaient qu’on les mélange. Exemple lié

    • Dans l’idéal, il faudrait que le compilateur ne fasse que vérifier les types, puis abaisse toute la logique métier restante en simples copies d’octets. Je ne suis pas sûr de bien comprendre ton intention, mais c’est ainsi que je le verrais.

  • Je pense que la règle du 80/20 s’applique aussi aux systèmes de types. Si on pousse trop loin, utiliser une bibliothèque devient coûteux et le gain réel est minime. Un UUID ou un String, c’est familier, mais des types comme AccountID ou UserID ne le sont pas, donc il faut les apprendre, ce qui a un coût. Un système de types élaboré peut avoir de la valeur, ou non, surtout si les tests sont déjà suffisants. Référence liée

    • De toute façon, pour utiliser un logiciel, il faut bien comprendre ce qu’est un Account ou un User, donc je ne pense pas qu’une fonction qui prend un AccountId, comme getAccountById, soit plus difficile à comprendre qu’une fonction qui prend un UUID.

    • En réalité, un String n’est qu’un ensemble d’octets et ne porte aucun sens. En revanche, avec AccountID, on comprend généralement qu’il s’agit de « l’identifiant d’un compte ». Si l’on veut vraiment connaître la représentation interne, il suffit d’aller voir la définition du type, mais dans la plupart des contextes, il suffit de savoir ce qu’est AccountID. Un type est simplement moins ambigu à l’usage quand il a un nom explicite. Le lien grugbrain.dev est même plutôt trop élémentaire ; avec un grug brain, on serait justement favorable à ce niveau de séparation des types.

    • foo(UUID, UUID) est bien moins souhaitable que foo(AccountId, UserId). C’est auto-explicatif, et si l’on inverse accidentellement l’ordre des arguments, le compilateur peut le détecter. Cela permet aussi d’écrire plus clairement des structures de données complexes, sans devoir créer de nouveaux types.

      Map<UUID, List<UUID>>
      Map<AccountId, List<UserId>>
      
    • À propos de l’idée que « UUID ou String, c’est déjà familier » : en pratique, il est difficile de savoir précisément sous quelle forme un UUID est stocké ou converti — GUIDv1, UUIDv4, UUIDv7, etc. Par expérience, avec une combinaison Java + MS SQL, j’ai déjà dû corriger moi-même des problèmes de conversion d’endian entre UUID et uniqueidentifier. J’imagine que c’est le même type de galère que les conversions automatiques de fuseaux horaires dans les bases de données.

    • En fait, connaître ces types était de toute façon nécessaire ; sinon, on aurait simplement risqué de transmettre des données erronées à une fonction.

  • Récemment, dans notre équipe, nous avons aussi commencé à appliquer des types à plusieurs valeurs numériques mélangées dans du code C++. L’idée est venue en corrigeant un bug : nous avons introduit des types sûrs, et cela a révélé trois autres endroits où des valeurs similaires étaient mal utilisées.

  • La bibliothèque mp-units (documentation officielle de mp-units) me fait penser à un bon exemple axé sur les problèmes d’unités physiques. Avec des types d’unités puissants, on gagne en sécurité, la logique complexe de conversion d’unités est automatisée, et on peut traiter différentes unités dans du code générique. J’ai essayé d’introduire cela dans l’univers Prolog, mais mes collègues autour de moi n’étaient pas très réceptifs. Exemple pour Prolog

    • J’ai déjà travaillé sur un projet qui manipulait plusieurs grandeurs physiques — distance, vitesse, température, pression, etc. — et tout passait simplement en float, si bien qu’on pouvait envoyer une distance là où une vitesse était attendue sans aucun problème à la compilation, le bug n’apparaissant qu’à l’exécution. Même chose pour les erreurs d’unité, comme km/h contre miles/h. J’aurais voulu augmenter le nombre de types pour attraper ces erreurs dès le développement, mais j’étais junior à l’époque et difficilement en position de convaincre qui que ce soit.

    • J’avais abandonné l’idée d’appliquer des types à chaque unité physique, de peur que cela soit trop complexe, mais je compte regarder mp-units. Les problèmes viennent souvent du fait qu’on n’indique pas clairement dans quelle unité une variable est exprimée. Avec des données externes ou des fonctions standard, l’unité n’est souvent pas précisée.

  • En C#, je crée des types comme ceci

    readonly struct Id32<M> {
      public readonly int Value { get; }
    }
    

    Puis

    public sealed class MFoo { }
    public sealed class MBar { }
    Id32<MFoo> x;
    Id32<MBar> y;
    

    Cela permet de distinguer différents identifiants entiers. On peut aussi l’étendre à IdGuid, IdString, etc., et il suffit d’ajouter une ligne pour un nouveau type marqueur (M). J’utilise des variantes similaires en TypeScript et en Rust.

    • J’ai déjà utilisé un pattern similaire. Et pour des ID de type int, un enum a sans doute le moins de friction, mais cela m’a paru trop source de confusion pour l’utiliser dans du vrai code. Discussion liée

    • Ce pattern est appelé « phantom type », car les valeurs de MFoo ou MBar n’existent pas à l’exécution.

    • Il existe aussi des bibliothèques comme Vogen pour cet usage. Vogen signifie Value Object Generator et permet d’ajouter des types de value object via génération de code source. Le readme référence aussi des bibliothèques similaires.

  • J’avais déjà vu cette approche auparavant sans en comprendre le but. Aujourd’hui encore, en écrivant une fonction qui prend trois arguments de type chaîne, je me demandais s’il fallait forcer un parsing typé à l’entrée ou le faire à l’intérieur de la fonction ; en fait, je n’avais même pas besoin des valeurs parsées dans ce cas précis, donc cette méthode est exactement la réponse que je cherchais. Cela aura probablement l’impact le plus fort sur mon style de code cette année.

  • Mon ami Lukas a résumé cette idée sous le nom de « Safety Through Incompatibility ». J’ai appliqué ce pattern partout dans du code Go et je l’ai trouvé extrêmement utile. Cela empêche à la racine de transmettre le mauvais identifiant.
    Article lié 1
    Article lié 2

  • En Swift, il existe le mot-clé typealias, mais si le type sous-jacent est le même, ils restent librement convertibles entre eux, donc ce n’est pas vraiment adapté à cet objectif. Un wrapper sous forme de struct est idiomatique en Swift, et avec ExpressibleByStringLiteral, cela reste relativement pratique. Mais j’aimerais qu’il existe un nouveau mot-clé, quelque chose comme un « strong typealias » (typecopy, par exemple), qui permette d’exprimer : « c’est juste un String, mais un String porteur d’un sens particulier, donc ne le mélangez pas avec d’autres String. »

    • En pratique, la plupart des langages fonctionnent ainsi. Rust, C et C++ aussi, par exemple. Comme dans l’exemple Go, c’est agréable quand on peut éviter de créer un type wrapper. En C++, il faut être encore plus prudent, car si un constructeur n’est pas marqué explicit, on peut librement passer un int à la place d’un Foo.

    • Même si cela paraît élégant en théorie, l’application en pratique peut être compliquée. En C++, on se retrouve à se demander comment l’envoyer dans std::cout, ou comment rester compatible avec des fonctions tierces ou des points d’extension existants qui attendent un String.

    • Haskell dispose de ce concept via newtype. Dans les langages orientés objet, si un type n’est pas final, on peut facilement créer des sous-classes pour y ajouter ou spécialiser des comportements. C’est simple, peu coûteux, et ne nécessite ni wrapper supplémentaire ni boxing. En Java, en revanche, String est final, donc cette approche est difficile, et spécialiser String lui-même l’est aussi.

    • Plus concrètement, je me demande en quoi tu voudrais que cela se comporte différemment d’un wrapper de type struct.

 
brain1401 2025-07-28

Rust s’utilise aussi de cette façon, n’est-ce pas ? Ça me semble clairement être une bonne chose.

 
regentag 2025-07-28

Si on utilisait un langage doté d’un bon système de types, on n’aurait peut-être pas pu éviter ce genre de problème..?
Disparition de la sonde Mars Climate Orbiter de la NASA en septembre 1999

  • À cause d’un problème d’interopérabilité des données entre un module utilisant les livres pour exprimer la force et un autre utilisant les newtons, la sonde a été mal pilotée et s’est écrasée.