17 Punkte von GN⁺ 2025-07-26 | 8 Kommentare | Auf WhatsApp teilen
  • Beim Programmieren kann man das Typsystem nutzen, um unterschiedliche Bedeutungen von Daten klar voneinander zu trennen
  • Allgemeine Typen wie Strings oder Integer unverändert zu verwenden lässt den Kontext verloren gehen und kann zu Bugs führen
  • Selbst bei demselben Basistyp lassen sich durch die Definition neuer, zweckgebundener Typen Fehler zur Compile-Zeit vermeiden
  • In der Go-Bibliothek libwx werden Typen zur klaren Trennung von Maßeinheiten definiert, um Fehler durch die Vermischung von float64 zu verhindern
  • Im Beispielcode werden UUID-Typen in UserID und AccountID aufgeteilt, sodass der Compiler falsche Verwendung blockiert
  • Auch in Sprachen mit weniger starkem Typsystem wie Go lassen sich mit einfachen Typ-Wrappern Bugs vermeiden

Nutzen wir das Typsystem aktiv

Der Ausgangspunkt des Problems: Vermischung einfacher Typen

  • In der Programmierung werden viele Werte oft nur mit grundlegenden Typen wie string, int oder UUID dargestellt
  • Wenn ein Projekt jedoch größer wird, häufen sich Fehler, bei denen solche einfachen Typen ohne Unterscheidung miteinander vermischt werden
    • Beispiel: Eine userID-Zeichenkette wird versehentlich als accountID übergeben, oder bei einer Funktion mit drei int-Argumenten wird die Reihenfolge vertauscht

Die Lösung: Typdefinitionen, die die Absicht sichtbar machen

  • int oder string sind nur Bausteine; wenn man sie im ganzen System unverändert weiterreicht, geht bedeutungsvoller Kontext verloren
  • Um das zu verhindern, sollte man für jede Rolle einen eigenen Typ definieren und verwenden
    • Beispiel:
      type AccountID uuid.UUID  
      type UserID uuid.UUID  
      
      func UUIDTypeMixup() {  
          {  
              userID := UserID(uuid.New())  
              DeleteUser(userID)  
              // kein Fehler  
          }  
      
          {  
              accountID := AccountID(uuid.New())  
              DeleteUser(accountID)  
              // Fehler: AccountID kann nicht als UserID verwendet werden  
          }  
      
          {  
              accountID := uuid.New()  
              DeleteUserUntyped(accountID)  
              // kein Compile-Zeit-Fehler, hohes Risiko für Probleme zur Laufzeit  
          }  
      }  
      
  • So lassen sich Argumente mit falschem Typ bereits zur Compile-Zeit blockieren

Praxisbeispiel: die Bibliothek libwx

  • Der Autor setzt diese Technik in seiner Go-Bibliothek libwx bereits ein
  • Für alle Maßeinheiten werden eigene Typen definiert, und Methoden zur Umrechnung von Einheiten sind an diese Typen gebunden
    • Beispiel: Mit der Methode Km.Miles() werden Einheiten klar voneinander getrennt
  • Unten ein Beispiel dafür, wie der Compiler eine falsche Argumentreihenfolge und die Verwechslung von Einheiten blockiert:
    // Temperatur in Fahrenheit deklarieren  
    temp := libwx.TempF(84)  
    
    // relative Luftfeuchtigkeit deklarieren (Prozent)  
    humidity := libwx.RelHumidity(67)  
    
    // Fälschlich an eine Funktion übergeben, die Celsius statt Fahrenheit verlangt  
    fmt.Printf("Dew point: %.1fºF\n",  
      libwx.DewPointC(temp, humidity))  
    // der Compiler erkennt den Typ-Mismatch sofort  
    // temp (Typ TempF) kann nicht als TempC verwendet werden  
    
    // Argumentreihenfolge der Funktion falsch übergeben  
    fmt.Printf("Dew point: %.1fºF\n",  
      libwx.DewPointF(humidity, temp))  
    // der Compiler verhindert den Fehler bei den Argumenttypen  
    
  • Fehler, die bei bloßer Verwendung von float64 auftreten könnten, lassen sich vollständig vermeiden

Fazit: Das Typsystem aktiv nutzen

  • Das Typsystem dient nicht nur der Syntaxprüfung, sondern ist ein Werkzeug zur Vermeidung von Bugs
  • Für jedes Modell sollte man eigene ID-Typen definieren, und auch Funktionsargumente sollten statt mit float oder int mit klaren Typen gekapselt werden
  • Dieser Ansatz ist auch in Sprachen mit weniger starkem Typsystem wie Go sehr effektiv und einfach umzusetzen
  • In der Praxis gibt es wirklich viele Bugs durch die Vermischung von UUID- oder String-Typen
  • Der Autor betont, dass es erstaunlich sei, dass diese einfache Methode in produktivem Code nicht häufiger eingesetzt wird

Zugehöriger Code

8 Kommentare

 
vk8520 2025-07-29

Soweit ich weiß, kann es bei der Verwendung in Kotlin zu Performance-Problemen kommen, weil Primitive in Wrapper verpackt werden und dann nicht auf dem Stack, sondern auf dem Heap gespeichert werden. Natürlich hat in den meisten Use Cases die Wartbarkeit Vorrang. Außerdem lassen sich Performance-Probleme mit value class minimieren.

 
regentag 2025-07-28

Die Sprache Ada verfügt in dieser Hinsicht über ein sehr hervorragendes Typsystem. Werte unterschiedlicher Art lassen sich unkompliziert als separate Typen deklarieren, und wenn sie vermischt werden, fängt der Compiler das zuverlässig ab.

 
roxie 2025-07-28

Ich frage aus Neugier. Gibt es auch andere Vorteile, die sie von anderen verbreiteten Typsprachen unterscheiden? (Kotlin, Rust, TypeScript, ...)

 
regentag 2025-07-28

Der Vorteil von Ada liegt meist eher in der Richtung „besser als C“. In C wird vieles eingeschränkt, weil man dem Entwickler vertraut und dadurch Dinge erlaubt sind, die problematisch sein können. Etwa implizite Typumwandlungen. Die meisten Entwickler scheinen aber trotzdem C mehr zu mögen, vermutlich weil sie daran gewöhnt sind...

Es mag an der Codebasis liegen, an der ich arbeite, aber wir deklarieren und verwenden fast alles als eigene Typen. Primitive Typen verwenden wir eigentlich nur noch für Array-Indizes.

 
roxie 2025-07-28

Verstanden, danke.

 
GN⁺ 2025-07-26
Hacker-News-Kommentar
  • Ich mag diesen Ansatz, dieses „make bad state unrepresentable“, also schlechte Zustände schon auf Darstellungsebene unmöglich zu machen. Ein häufiges Problem bei diesem Muster ist aber, dass Entwickler beim ersten Schritt der Typ-Implementierung stehen bleiben. Dann wird alles zu einem Typ, nichts ist mehr richtig kompatibel, und es entstehen viele leicht abgewandelte Typen, wodurch sich Code schwer verfolgen und verstehen lässt. In so einer Situation würde ich fast lieber eine schwach typisierte dynamische Sprache (JS) oder eine stark typisierte dynamische Sprache (Elixir) verwenden. Wenn Entwickler den typgetriebenen Flow jedoch konsequent weiterziehen, also Bedingungslogik in pattern-matchbare Union-Typen verlagern und Delegation gut nutzen, wird die Developer Experience wieder angenehm. Zum Beispiel kann eine Funktion wie DewPoint auch dann natürlich funktionieren, wenn sie mehrere Typen annimmt.

    • Deshalb hätte ich gern, dass mehr Sprachen bounded types nativ unterstützen, also Typen mit Bereichsbeschränkung. Statt nur x: u32 sollte man im Typsystem erzwingen können, dass x nur im Bereich [0,10) liegen darf. Dann wären bei Array-Indizierung keine Bounds-Checks mehr nötig. Auch bei Dingen wie Option würden Peephole-Optimierungen deutlich einfacher. In Rust gibt es dank LLVM innerhalb von Funktionen teilweise Unterstützung dafür, aber nicht bei der Übergabe von Variablen zwischen Funktionen.

    • Nur zur Klarstellung: Ruby ist nicht schwach, sondern stark typisiert. Bei einem Ausdruck wie 1 + "1" bekommt man einen Fehler wie TypeError: String can't be coerced into Integer.

    • Das Problem ist dieses „Beim ersten Schritt der Typ-Implementierung stehen bleiben“. Zum Beispiel ist es ein guter Anfang, int in eine struct zu kapseln und als UUID zu verwenden. Aber sobald jemand einfach irgendeinen int nimmt, ihn in diesen Typ wrappt und weiterreicht, ist die Eigenschaft verloren, dass diese UUID tatsächlich eindeutig sein sollte. Am Ende ist „Correct by construction“ entscheidend: Ein Typ, der eindeutig sein muss, wie eine UUID, darf gar nicht erzeugt werden können, solange das nicht wirklich nachgewiesen ist, sei es durch eine Funktion, einen Konstruktor oder etwas Ähnliches, das sonst eine Exception wirft. Dieses Konzept gilt nicht nur für UUIDs, sondern für beliebige Typen und Invarianten.

    • In letzter Zeit folge ich dem Red-Green-Refactor-Muster, aber statt mit fehlschlagenden Tests mache ich das Typsystem strenger, sodass Bugs schon vom Type Checker abgefangen werden. Neue Features, Edge Cases und Bugs, die sich nicht über Typen erzwingen lassen, behandle ich weiterhin mit Tests. Aber Red-Green-Refactor über das Typsystem ist im Allgemeinen schneller und kann eine ganze Klasse von Bugs vollständig verhindern.

    • Mit strukturellen Typen lässt sich der Großteil dieser Probleme abmildern. Und wenn es wirklich nötig ist, kann man mit nominalen Typen immer noch stärker erzwingen.

  • Im Umfeld von Exceptions und Typen finde ich auch, dass man checked exceptions sinnvoll nutzen sollte, um sie typspezifisch passend zu behandeln. Ich verstehe nicht, warum checked exceptions in Java so viel Kritik bekommen. In einem Projekt, das ich betreut habe, habe ich ihre Verwendung erzwungen. Anfangs hasste das jeder, aber sobald sich alle daran gewöhnt hatten, bei jedem Exception-Fall den gesamten Code-Flow mitzudenken, fanden es am Ende doch alle gut. Bei Unit-Tests waren wir nicht ganz so streng, aber das Projekt wurde dadurch sehr robust.

    • Die Beschwerden über checked exceptions in Java kommen daher, dass Exception-Handling zu umständlich ist. Als Bibliotheksautor kann man checked exceptions oft nicht sauber festlegen, und auf Client-Seite muss man bei jedem Funktionsaufruf unnötig Exceptions behandeln, was schnell frustriert. Wenn man Exceptions einfacher in andere Typen oder Runtime-Exceptions umwandeln könnte oder sie auf Modul-/App-Ebene nur deklarieren müsste, wäre vieles besser, aber so ist es einfach zu sperrig. Außerdem brechen Signaturen schnell, weshalb man domänenspezifische Exceptions verwenden sollte, aber Java macht selbst das Umwandeln von Exceptions unbequem. Checked exceptions an sich sind gut, ich mag nur die Usability von Java-Exceptions nicht.

    • Checked exceptions wurden vor allem wegen ihres Missbrauchs kritisiert. Dass Java sowohl checked als auch unchecked exceptions unterstützt, ist eigentlich eine gute Designentscheidung. Idealerweise nutzt man checked exceptions nur für das, was Eric Lippert „exogenous“ exceptions nennt, und wandelt den Rest in unchecked exceptions um. Zum Beispiel kann eine DB-Verbindung jederzeit abbrechen, aber es ist viel zu umständlich, throws SQLException die ganze Call-Stack hochzuschleppen. Man kann das ganz oben mit einem Catch-all behandeln und HTTP 500 zurückgeben. Verwandter Artikel

    • Der Nachteil checked exceptions gegenüber unchecked ist, dass man bei einer tief im Call-Stack geänderten Funktion, die nun Exceptions werfen kann, möglicherweise nicht nur den Handler, sondern alle Funktionen dazwischen anpassen muss. Das macht Systemänderungen weniger flexibel. Die Diskussion um async function coloring geht in eine ähnliche Richtung: Wenn etwas Exceptions werfen kann, muss man es entweder mit try/catch umgeben oder der Aufrufer muss ebenfalls deklarieren, dass er Exceptions weiterwirft.

    • C# hat ein klares Typsystem, setzt aber auf unchecked exceptions. Der Error-Stack bleibt damit sauber und das funktioniert gut. Das ist sauberer, als auf jeder Ebene pattern-gematchte Exception-Handler mit maßgeschneiderter Logik zu haben. Mit robusten unwrapped error results käme man vermutlich auf ein ähnliches Ergebnis.

    • In Java ist die Usability checked exceptions einfach schwach. Ein Beispiel ist die Stream-API: Wenn in map- oder filter-Funktionen checked exceptions geworfen werden, wird es richtig unerquicklich. Wenn mehrere Service-Aufrufe jeweils ihre eigenen checked exceptions haben, landet man am Ende entweder bei catch (Exception) oder bei absurd langen Exception-Listen.

  • Insgesamt stimme ich der Idee zu, „eigene Typen zu machen“, aber ich habe oft unter Systemen gelitten, in denen wirklich alles ein eigener Typ ist. Besonders schwierig wird es, wenn Code zum bloßen Verschieben von Bytes mit Domänenlogik vermischt ist.

    • Ich verstehe das Gefühl. Die benötigten Daten sind eigentlich schon da, aber zuerst muss man herausfinden, wie man überhaupt den Typ oder eine Instanz daraus erzeugt. Ohne Rezept fühlt es sich an, als würde man mit der Dokumentation kämpfen. Zum Beispiel hat man ein Objekt {x, y, z}, muss aber zuerst createVector(x, y, z): Vector aufrufen, und für ein Face dann createFace(vertices: Vector[]): Face, wodurch alles unnötig prozedural wird. Bei Dingen wie BouncyCastle hat man vielleicht bereits ein Byte-Array, muss aber erst mehrere Typen bauen und deren Methoden aufrufen, bevor man die eigentlich gewünschte Funktion nutzen kann.

    • In Go ist es ziemlich einfach, einen Type Alias wieder auf den Ursprungstyp zurückzuführen, etwa AccountID → int. Mit einer sauberen Struktur kann man daher im Stil von Clean Architecture arbeiten: Die Domänenlogik nutzt Type Aliases, während Bibliotheken, denen die Domäne egal ist, auf höherer oder niedrigerer Abstraktionsebene umwandeln und dort verarbeiten. Allerdings braucht man dafür sehr viel Konvertierungscode.

    • Phantom Types sind in solchen Fällen nützlich. Man fügt einen Typparameter hinzu, also ein Generic, verwendet ihn aber nirgends konkret. Ich habe das früher in Scala für Kryptocode genutzt: Zur Laufzeit waren alle Arrays einfach nur Bytes, aber durch Phantom Types ließ sich verhindern, dass unterschiedliche Dinge versehentlich vermischt werden. Verwandtes Beispiel

    • Im Idealfall würde der Compiler nach der Typprüfung die gesamte verbleibende Domänenlogik einfach auf simples Kopieren von Bytes herunterkompilieren, falls ich deine Absicht richtig verstanden habe.

  • Ich denke, auch auf Typsysteme trifft die 80/20-Regel zu. Wenn man es übertreibt, wird die Nutzung einer Bibliothek anstrengend und der zusätzliche Nutzen ist gering. Mit UUID oder String ist man vertraut, aber AccountID oder UserID muss man erst lernen, und das kostet. Ein elaboriertes Typsystem kann sinnvoll sein oder auch nicht, besonders wenn es ohnehin gute Tests gibt. Siehe auch

    • Um Software überhaupt verwenden zu können, muss man sowieso verstehen, was ein Account oder User ist. Daher ist eine Funktion wie getAccountById, die ein AccountId erwartet, meiner Meinung nach nicht schwerer zu verstehen als eine Funktion, die ein UUID erwartet.

    • Tatsächlich ist ein String nur eine Menge von Bytes und trägt von sich aus keinerlei Bedeutung. Bei AccountID weiß man hingegen meist sofort, dass es um die ID eines Kontos geht. Wenn man wirklich die interne Repräsentation wissen will, kann man sich die Typdefinition ansehen, aber in den meisten Kontexten reicht es zu wissen, was ein AccountID ist. Typen werden am Ende einfach leichter nutzbar, wenn sie klare Namen haben. Der Link zu grugbrain.dev ist im Gegenteil fast zu grundlegend; ein grug brain würde diese Art von Typtrennung eher gutheißen.

    • foo(UUID, UUID) ist deutlich schlechter als foo(AccountId, UserId). Letzteres ist selbsterklärend, und wenn man beim Aufruf versehentlich die Reihenfolge vertauscht, kann der Compiler das erkennen. Das bleibt auch bei komplexeren Datenstrukturen gut lesbar, ohne dass man neue Typen erfinden muss.

      Map<UUID, List<UUID>>
      Map<AccountId, List<UserId>>
      
    • Zur Aussage „UUID oder String ist eben schon vertraut“: In der Praxis ist oft gar nicht so leicht zu verstehen, welche Form von UUID gemeint ist, etwa GUIDv1, UUIDv4 oder UUIDv7, und wie sie gespeichert oder konvertiert wird. Ich habe zum Beispiel in einer Java+MS-SQL-Kombination schon selbst Endianness-Probleme bei der Umwandlung zwischen UUID und uniqueidentifier beheben müssen. Das erinnert mich an ähnliche Probleme mit automatischen Datenbank-Zeitzonenumwandlungen.

    • Eigentlich musste man diese Typen sowieso kennen, sonst hätte man am Ende nur falsche Daten an Funktionen weitergereicht.

  • Unser Team hat vor Kurzem auch in C++ Typen für mehrere gemischt verwendete Zahlenwerte eingeführt. Anlass war ein Bugfix: Wir haben sichere Typen eingebaut und dadurch noch drei weitere Stellen gefunden, an denen ähnliche falsche Werte verwendet wurden.

  • Die Bibliothek mp-units (mp-units-Dokumentation) ist ein gutes Beispiel dafür, wie man solche Probleme mit physikalischen Einheiten adressieren kann. Mit starken Einheitentypen gewinnt man Sicherheit, automatisiert komplexe Umrechnungslogik und kann mit generischem Code unterschiedliche Einheiten verarbeiten. Ich wollte so etwas in die Prolog-Welt übertragen, aber mein Umfeld war nicht besonders begeistert. Beispiel für Prolog

    • Ich habe einmal an einem Projekt gearbeitet, das viele physikalische Größen behandelte, etwa Distanz, Geschwindigkeit, Temperatur und Druck. Alles wurde einfach als float weitergereicht, sodass man problemlos einen Distanzwert an eine Stelle für Geschwindigkeit setzen konnte. Der Compiler akzeptierte das, und der Bug tauchte erst zur Laufzeit auf. Dasselbe galt für falsch übergebene Einheiten wie km/h statt miles/h. Ich hätte solche Probleme gern schon in der Entwicklungsphase durch zusätzliche Typen abgefangen, aber ich war damals noch Junior und konnte das schwer durchsetzen.

    • Ich hatte das Thema typisierte physikalische Einheiten irgendwann aufgegeben, weil es mir zu komplex erschien, aber ich plane nun, mir mp-units genauer anzusehen. Besonders problematisch ist, dass bei Variablen oft gar nicht klar markiert ist, in welcher Einheit sie vorliegen. Bei externen Datenquellen oder Standardfunktionen fehlt diese Kennzeichnung häufig.

  • In C# definiere ich Typen manchmal so:

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

    Dann kann man etwa schreiben:

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

    Auf diese Weise lassen sich verschiedene Integer-IDs sauber voneinander trennen. Das kann man auch auf IdGuid oder IdString erweitern, und für einen neuen Marker-Typ M braucht man nur eine zusätzliche Zeile. In TypeScript und Rust verwende ich ähnliche Varianten.

    • Ich habe ein ähnliches Muster schon einmal verwendet. Und wenn es um int-IDs geht, wäre ein enum wahrscheinlich die Variante mit der geringsten Reibung, aber es wirkte mir zu verwirrend, sodass ich es nie produktiv eingesetzt habe. Verwandte Diskussion

    • Dieses Muster nennt man „phantom type“, weil MFoo oder MBar zur Laufzeit gar keinen Wert haben.

    • Für diesen Zweck gibt es auch Bibliotheken wie Vogen. Vogen steht für Value Object Generator und unterstützt per Source Code Generation das Hinzufügen von Value-Object-Typen. Im README finden sich auch ähnliche Bibliotheken und weiterführende Links.

  • Ich hatte diese Methode schon früher einmal gesehen, aber nicht verstanden, wozu sie gut ist. Heute habe ich wieder eine Funktion mit drei String-Parametern geschrieben und überlegt, ob ich explizites Typ-Parsen erzwingen oder das innerhalb der Funktion erledigen sollte. Da ich die geparsten Werte in Wirklichkeit gar nicht brauchte, war dieser Ansatz genau die Antwort, nach der ich gesucht hatte. Das wird vermutlich meinen Coding-Stil in diesem Jahr am stärksten beeinflussen.

  • Mein Freund Lukas hat diese Idee einmal als „Safety Through Incompatibility“ beschrieben. Ich habe dieses Muster flächendeckend in Golang-Code angewendet und fand es extrem nützlich. Es verhindert von vornherein, dass versehentlich die falsche ID übergeben wird.
    Verwandter Artikel 1
    Verwandter Artikel 2

  • In Swift gibt es zwar das Schlüsselwort typealias, aber wenn der zugrunde liegende Typ gleich ist, lassen sich die Typen trotzdem frei ineinander umwandeln. Für diesen Zweck ist das also praktisch ungeeignet. Wrapper-Structs sind in Swift idiomatischer, und mit ExpressibleByStringLiteral wird das halbwegs bequem. Trotzdem hätte ich gern ein neues Schlüsselwort wie ein „starker Type Alias“ (typecopy oder ähnlich), mit dem man ausdrücken kann: „Das hier ist zwar im Grunde ein String, aber ein String mit besonderer Bedeutung, also bitte nicht mit anderen Strings vermischen.“

    • So funktioniert es in den meisten Sprachen. Das gilt zum Beispiel auch für Rust, C und C++. Es ist angenehm, wenn man wie im Go-Beispiel nicht immer Wrapper-Typen bauen muss. In C++ muss man zusätzlich aufpassen: Wenn ein Konstruktor nicht als explicit markiert ist, kann man einen int oft einfach so an Stellen übergeben, an denen ein Foo erwartet wird.

    • In der Theorie wirkt das elegant, aber in der Praxis kann die Anwendung kompliziert werden, zum Beispiel bei std::cout in C++ oder bei der Kompatibilität mit Third-Party-Funktionen und Extension Points, die bisher einfach String erwartet haben.

    • In Haskell gibt es dieses Konzept als newtype. In OOP-Sprachen kann man, solange ein Typ nicht final ist, relativ leicht Unterklassen erzeugen und gewünschtes Verhalten ergänzen oder spezialisieren. Das ist günstig und einfach, ohne zusätzliche Wrapper oder Boxing. In Java ist String allerdings final, weshalb dieser Weg dort schwierig ist und sich String selbst kaum spezialisieren lässt.

    • Mich würde konkret interessieren, wie genau du dir das Verhalten im Unterschied zu einem Wrapper-Struct vorstellst.

 
brain1401 2025-07-28

Rust wird ja auch auf diese Weise verwendet, das scheint definitiv gut zu sein.

 
regentag 2025-07-28

Wenn man eine Sprache mit einem gut ausgearbeiteten Typsystem verwendet, hätte man so etwas vielleicht auch verhindern können ...
Das Verschwinden des NASA Mars Climate Orbiter im September 1999

  • Wegen eines Problems bei der Datenkopplung zwischen einem Modul, das zur Darstellung von Kraft die Einheit Pfund verwendete, und einem Modul, das Newton verwendete, wurde die Sonde falsch gesteuert und stürzte ab.