Direkt zum Hauptinhalt springen

Felix Nehrke

Ich schreibe hier über Softwareentwicklung und Softwarearchitektur.

english

Branded Types

Typescript ist im Grunde nur Javascript mit etwas strengeren Regeln und leistungsstarken Typen. Diese einfache Ergänzung ist tatsächlich überraschend nützlich und ermöglicht uns eine leistungsstarke Autovervollständigung in unseren Editoren und IDEs. Typescript gibt uns auch schnelles Feedback, wenn wir Fehler gemacht haben und Typkonflikte verursachen. Kein Wunder also, dass viele Entwickler es gerne nutzen und seine Vorteile genießen. Doch wie nutzen wir eigentlich Typen und was können wir besser machen? Lasst mich darum branded types vorstellen, um unsere Entwicklung weiter zu verbessern und das Beste daraus zu machen.

Was sind branded types

Kurz gesagt, branded types, helfen dabei, mehrdeutige Parameter zu vermeiden. Oder, anders ausgedrückt, helfen sie uns, zwischen den Werten desselben primitiven Typs zu unterscheiden. Beispielsweise können wir so bei einer Funktion mit mehreren Parametern desselben primitiven Typs (z.B. string) die Parameterreihenfolge eindeutig gestalten. Dieses Verfahren wird manchmal auch als type-tagging bezeichnet.

Da wir durch branded types immer den richtigen Typ verwenden, können wir somit schwer zu findende Fehler vermeiden. Außerdem steigern wir mit ihrer Hilfe die Ausdrucksstärke unseres Codes. Im Wesentlichen kennzeichnen sie einen Parameter für einen ganz bestimmten Anwendungsfall und verbergen den zugrunde liegenden primitiven Typ vor uns. So können wir die Gewissheit genießen, dass es sehr unwahrscheinlich ist, dass wir unsere Parameterreihenfolge und Typdefinitionen im Allgemeinen durcheinander bringen.

Die große Verbesserung gegenüber anderen Lösungen für strenge Typen ist ihre Fähigkeit, zum primitiven Typ zu kompilieren. Damit sie unser endgültiges Artefakt nicht mit unnötigen Codefragmenten verunreinigen.

Ein Beispiel um die Idee zu verstehen

Stellen Sie sich vor, Sie haben eine Funktion, die eine Benutzer-ID und eine Gruppen-ID akzeptiert, und beide sind vom Typ „String“. Diese Funktion könnte so aussehen:

function canAccess(groupId: string, userId: string): boolean {
  // implementation
}

In diesem Fall macht die Benennung der Parameter deutlich, welcher Parameter für die Benutzer-ID und welcher für die Gruppen-ID stehen. Der Compiler hindert uns jedoch nicht daran, die Funktion mit Parametern in der falschen Reihenfolge aufzurufen. Dies bedeutet, dass beide Aufrufe des folgenden Beispiels kompiliert werden und keine Warnung ausgegeben wird.

const userId = "123";
const grouId = "abc";
const allow1 = canAccess(groupId, userId); // correct order
const allow2 = canAccess(userId, groupId); // wrong order, no error
Manche IDEs zeigen hier vielleicht einen Hinweis an, dass die Reihenfolge falsch ist. Dies gilt jedoch nicht für alle Editoren und wir können uns nicht darauf verlassen.

Einführung dedizierter Klassen

Der erste Ansatz, der mir in den Sinn kommt, sind Klassen zur Darstellung der Typen und zur Vermeidung dieses ärgerlichen Umstands. Dies ist in vielen anderen Sprachen wie Java bereits eine bewährte Praxis, insbesondere um die Typen auf bestimmte Fälle einzuschränken und Fehler zu vermeiden. Erstellen wir also eine Klasse namens UserId und eine weitere namens GroupId, um die IDs zu unterscheiden.

class UserId {
  constructor(public userId: string) {}
}

class GroupId {
  constructor(public groupId: string) {}
}

Unser tatsächlicher Code ändert sich ein wenig, aber wir erhalten eine Fehlermeldung, die uns mitteilt, dass wir einen Fehler bei den Parametern gemacht haben.

// note the class initialisation `new ...`
const userId = new UserId("123");
const groupId = new GroupId("abc");
const allow1 = canAccess(groupId, userId); // correct order
const allow2 = canAccess(userId, groupId); // wrong order, error

Die eigentliche Fehlermeldung ist eher beschreibend und erklärt detailliert, was schief gelaufen ist. Leider offenbart sie darüber hinaus ein kleines, aber wichtiges Implementierungsdetail, das wir ebenfalls ansprechen müssen.

Argument of type 'UserId' is not assignable to parameter of type 'GroupId'.
  Property 'groupId' is missing in type 'UserId' but required in type 'GroupId'.

Der einzige Grund, warum wir hier den Fehler erhalten, sind die unterschiedlichen Konstruktorparameter userId und groupId. Tatsächlich erhalten wir keinen Fehler, wenn beide id heißen. Diesen Fehler zu begehen ist sehr leicht und das ist ein Problem!

Abgesehen von diesem Detail konnten wir unser Problem lösen, leider hat es aber noch ein paar weitere fragwürdige Konsequenzen.

  1. wie serialisiert und de-serialisiert man diese Klassen richtig? z.B. für http-Anfragen

  2. Wir haben jetzt viel Code, der nur der Typsicherheit dient

  3. Selbst unser kompiliertes Artefakt enthält diesen Code, da der Compiler nicht herausfinden kann, ob der Code erforderlich ist

Auch wenn wir die letzten beiden Probleme akzeptieren und ignorieren, stehen wir immer noch vor dem ersten Problem. Folglich ist dies keine wirklich praktikable Lösung und wir müssen tiefergehende Untersuchungen durchführen.

Die Idee von Klassen zu Typen abstrahieren

Da die Klassen für unser Problem nicht geeignet sind, sollten wir vielleicht einen Schritt zurück gehen. Definieren wir die Typen direkt, um unsere Domänentypen darzustellen. Das scheint einfach, da Typescript erstklassige Unterstützung für Typen bietet!

type UserId = string;
type GroupId = string;

Es sieht in der Tat toll aus, ist aber leider keine Lösung. Der Typescript-Compiler versucht eifrig, Typen zu ihre primitiven Basistypen aufzulösen, und wir haben hier nur den Typ string neu definiert. Folglich werden unsere neuen schönen Typen zu string aufgelöst, und wir gewinnen nichts – außer einer viel besseren Dokumentation, yeah!

Den Typen von seiner primitiven Basis unterscheiden

Unser Problem besteht also darin, dass der Compiler versucht, Typen zu ihrer primitiven Basis aufzulösen. Was passiert, wenn wir stattdessen unseren Typ als nicht primitiv deklarieren? Versuchen wir es und fügen unserem Typ einen beliebigen statischen Wert hinzu, der den Typ ändert, aber sonst nichts.

type UserId = string & { __brand: 'UserId' };
type GroupId = string & { __brand: 'GroupId' };

Dieser Ansatz sieht vielversprechend aus und wird tatsächlich funktionieren, da unsere IDs keine bloßen Strings mehr sind. Andererseits bedeutet dies, dass wir dem Compiler jetzt explizit die Typen unserer IDs mitteilen müssen.

Zur Erklärung wir erweitern hier den string um ein weiteres Attribut __brand, welches per default 'UserId', bzw. 'GroupId' ist! Dadurch sind die Strings auf einmal unterschiedliche Typen, da sich der Wert des statischen Attributes unterscheidet!
// note the 'as UserId' and 'as GroupId'
const userId = "123" as UserId;
const groupId = "abc" as GroupId;
const allow1 = canAccess(groupId, userId); // correct order
const allow2 = canAccess(userId, groupId); // wrong order, error

Der Fehler, den wir vom Typescript erhalten, ist beschreibend. Die Erklärung mag zwar etwas zu detailliert sein, verrät aber keine geheime Implementierung:

Argument of type 'UserId' is not assignable to parameter of type 'GroupId'.
  Type 'UserId' is not assignable to type '{ __brand: "GroupId"; }'.
	Types of property '__brand' are incompatible.
  	Type '"UserId"' is not assignable to type '"GroupId"'.

Das wirklich tolle an diesem Ansatz ist seine Vielseitigkeit. Wir haben nicht nur eine gültige Typenunterscheidung geschaffen, sondern auch dafür gesorgt, dass wir die Nachteile des ersten Ansatzes vermeiden. Wenn wir diese Werte als JSON serialisieren oder deserialisieren oder sie in console.log() verwenden, werden sie tatsächlich wie ein string behandelt. Darüber hinaus verunreinigen wir das kompilierte Artefakt nicht mit unnötigem Code. Wir jonglieren nur mit Typen und diese werden während der Kompilierung entfernt!

Verallgemeinerung zu einem generellen branded type

Nachdem wir nun branded types verstanden haben, sollten wir das Konzept etwas weiter abstrahieren. Wenn wir uns die Typdefinitionen ansehen, können wir leicht einige Duplikate erkennen (& { __brand: Something }). Das ist schlecht, denn es handelt sich nicht nur um doppelten Code, sondern es wird auch nicht erklärt, warum er vorhanden ist.

Lasst uns diese Probleme lösen, indem wir einen Hilfstyp für branded types einführen. Ein solcher Typ ermöglicht es uns, die Implementierungsdetails zu kapseln und verbessert die Lesbarkeit unseres Codes! Darüber hinaus können wir das Branding selbst etwas verbessern, indem wir das Attribut __brand als readonly markieren und sie zu einem unique Symbol machen.

declare const __brand: unique symbol;
type Branded<T, K> = T & { readonly [__brand]: K }

type UserId = Branded<string, 'UserId'>;
type GroupId = Branded<string, 'GroupId'>;

const userId = "123" as UserId;
const groupId = "abc" as GroupId;

Zusammenfassung

Wir haben gesehen, wie einfach wir die Typsicherheit unseres Codes durch die Verwendung von branded types verbessern können. Die gezeigten Beispiele sind dabei nur die Spitze des Eisbergs. Ich denke dabei nicht nur an IDs, sondern auch an verschiedene Einheitensysteme, z. B. Währungen oder Metriken. Nie wieder EUR und USD vertauschen, oder Meter und Yard. Denkbar wäre auch, API-Rückgaben durch Brandings zu markieren, beispielsweise um erfolgreiche und nicht erfolgreiche Rückgaben zu unterscheiden.

Im Wesentlichen denke ich, dass zumindest das Wissen über solch eine leistungsstarke Ergänzung zum Werkzeugkoffer jedes Typescript-Entwicklers gehören muss. Ich hoffe also, dass ich etwas Inspiration verbreiten konnte, und ihr den gezeigten Typ bereits zur utilities.d.ts hinzugefügt habt.

Happy typing.