TypeScript ist ohne Frage eine der meistgenutzten Sprachen der modernen Webentwicklung. Sie hat ein mächtiges Typensystem und erlaubt den Entwicklern viele Freiheiten. Doch geht mit vielen Freiheiten leider zwangsläufig einher, dass dadurch auch geringere Code-Qualität ermöglicht wird. Auch kommt TypeScript nicht gänzlich ohne Ecken und Kanten aus. Dazu gehören verwirrende Konstrukte, häufige Fehlerquellen, sowie unerwartete und/oder unschöne Grenzen. Aber ich arbeite inzwischen schon eine ganze Weile mit der Sprache und habe einige Tricks und Tipps entdeckt, wie qualitativ hochwertiger Code leichter fällt.
Die kommenden drei Beiträge sollen dir der Umgang mit TypeScript erleichtert, dein Verständnis für die Sprache stärken und mit konkreten Handlungsschritten aufzeigen, wie problematischer Code direkt in den Ansätzen vermieden werden kann.
In diesem ersten Teil The Good geht es um Punkte, die TypeScript an sich gut macht, die allerdings selbst fortgeschrittene Entwickler gelegentlich verwirren können. Im zweiten Teil The Bad nenne ich Dinge, die TypeScript besser machen sollte, sowie auch Dinge, die Entwickler meiner Erfahrung nach nicht ernst genug nehmen. Im letzten Teil The Ugly stelle ich noch ein paar Fallen der Sprache dar, die besonders schwierig zu debuggen sind und von denen deshalb jeder Entwickler schon einmal gehört haben sollte.
Beginnen wir mit der …
Typisierung von Keys und Values
interface Groups {
favorites: number[];
latest: number[];
}
function showGroups(myGroups: Groups) {
const keys = Object.keys(myGroups);
// keys: string[]
const values = Object.values(myGroups);
// values: any[]
const entries = Object.entries(myGroups);
// entries: [string, any][]
}
Es ist keine Seltenheit, dass man in JavaScript basierend auf den Keys oder Values von Objekten arbeiten möchte. Doch wer hierzu die passende Funktion gefunden hat (z. B. Object.keys
), oder auch nur eine einfache for-in
Schleife verwenden möchte, merkt schnell, dass die Typisierung nicht so spezifisch ist, wie man sie gerne hätte.
In obigem Beispiel könnte man erwarten, dass die Variable keys
als keyof Groups
(hier "favorites" | "latest"
) typisiert sein und analog die values
Variable den Typen Groups[keyof Groups]
(hier number[]
) bekommen sollte.
Warum macht TypeScript uns hier das Leben schwer und führt sogar noch ein böses any
für die Values ein?
Natürlich können wir uns Hilfsfunktionen für intuitiv korrekte Typisierung schreiben, die Aliase für obige Funktionen bilden. Aber hier sollten wir uns der Auswirkungen bewusst sein und kommen auch nicht um das keyword as
herum. Denn tatsächlich hat die weniger strikte Typisierung einen stichhaltigen Grund. Mit der Typen-Deklaration einer Variable (hier z. B. der Funktionsparameter) geben wir in TypeScript lediglich eine Bedingung, welche Attribute ein Objekt minimal erfüllen soll. Wir schließen hingegen nicht aus, dass weitere Attribute vorhanden sind.
Nehmen wir also ein Beispiel mit einem erweiterten Interface:
interface ProfileInfo extends Groups {
userId: string; // uuid
}
Wir können die obige Funktion showGroups
natürlich auch mit Variablen vom Typen ProfileInfo
aufrufen, da dieses Groups
erfüllt. In dem Fall beinhaltet das Array keys
folglich auch den Wert "userId"
. Analog beinhaltet das Array values
ebenfalls auch einen string
-Wert.
Ich möchte hier nicht im Allgemeinen davon abraten, striktere Typisierungen zu verwenden, da diese häufig durchaus angemessen ist. Aber man sollte sich dessen bewusst sein, dass man damit entsprechend den Compiler bevormundet und die Typenkorrektheit nicht mehr garantiert ist.
Verwirrung um Nichts
Wir kennen aus JavaScript bereits die feine Unterscheidung zwischen undefined
und null
. Mit TypeScript bekommen wir noch zusätzlich die Typen never
und void
. Besonders für TypeScript-Einsteiger kann diese Vielzahl an Typen zur Verwirrung führen, weshalb ich die feinen Unterschiede kurz erläutern möchte.
null
beschreibt eine explizite Abwesenheit eines Wertes, während undefined
eine implizite Abwesenheit beschreibt. Beide sind nicht nur Typen, sondern haben auch den entsprechenden Laufzeitwert. Dies ist bei never
und void
hingegen nicht der Fall.
In TypeScript beschreibt der Typ void
im Wesentlichen, dass der Wert nicht beobachtet wird. Der häufigste Anwendungsfall ist für die Typisierung von Parametern wie zum Beispiel in function fn(cb: () => void) { /* ... */ }
, deren Return-Wert nicht von der Funktion fn
verwertet wird. Im Gegensatz zu () => undefined
wird hier aber nicht verlangt, dass cb
keinen Return-Wert haben darf (bzw. undefined
sein muss).
Der Typ never
besagt, dass etwas nicht erreicht werden kann. Eine Funktion, die never
zurückgibt, terminiert also niemals mit einem Rückgabewert (inklusive undefined
). Deshalb ist never
zum Beispiel sinnvoll, wenn eine Funktion immer einen Fehler wirft.
Daneben bietet sich never
in vielen tieferen Berechnungen mit Typen an und kann auch dort als „Fehler“-Resultat verwendet werden, um bei jeder Zuweisung zu never
einen Compilezeit-Fehler zu melden. Viele Beispiele hierzu können in der empfehlenswerten Library ts-essentials gefunden werden.
Folgendes Beispiel demonstriert eine gute Verwendung von never
, um eine schlanke emit
-Funktion zu definieren, die kind
und payload
Argumente annimmt. Hierbei ist sichergestellt, dass emit
je nachdem, ob laut MyEventPayloads
ein Payload erwartet wird, die entsprechende Signatur mit nur einem bzw. zwei Parametern für dieses Event definiert. Und um weitere Events zu deklarieren, muss lediglich das MyEventPayloads
Interface erweitert werden.
import type { PickKeys, NonNever } from "ts-essentials";
/**
* Compile-time type map for defined events.
*/
interface MyEventPayloads {
ping: never;
state: number;
}
type MyEventKindsWithPayload = keyof NonNever<MyEventPayloads>;
type MyEventKindsWithoutPayload = PickKeys<MyEventPayloads, never>;
function emit<Kind extends MyEventKindsWithoutPayload>(
kind: Kind
): void;
function emit<Kind extends MyEventKindsWithPayload>(
kind: Kind,
payload: MyEventPayloads[Kind]
): void;
function emit<Kind extends keyof MyEventPayloads>(
kind: Kind,
payload?: MyEventPayloads[Kind],
): void { /* ... */ }
// correct use
emit("ping");
emit("state", 42);
// incorrect use (type errors)
emit("ping", 42);
emit("state");
emit("state", "lorem ipsum");
Privat oder Privater?
Mit ECMAScript 2022 haben private Attribute und Methoden in Klassen Einzug gehalten. Diese werden mit einem #
-Präfix versehen und sind auch zur Laufzeit nicht von außen einsehbar. Eine Unterstützung in TypeScript gibt es bereits seit 2020 (Version 3.8). Doch schon weit länger kann man in TypeScript das private
Keyword verwenden, um Felder entsprechend zu markieren. Die Frage ist: Sollte man in modernem Code die JavaScript- oder TypeScript-Variante verwenden? #
oder private
?
Im Gegensatz zu der ECMAScript-Syntax bietet das private
Keyword keinen Schutz des Feldes zur Laufzeit. Das Attribut kann also von außen eingesehen und auch verändert werden. Da JavaScript Code aber im Allgemeinen nicht als geschützt gegen Änderungen angesehen werden sollte, ist dieser Vorteil nur in den seltensten Fällen relevant. Ein womöglich relevanter Unterschied ist das Vorhandensein in einer Auflistung der Keys von Instanzen der Klasse. Aber auch das ist ein Anwendungsfall, für den mir kein sinnvoller Bedarf einfällt.
Die Vorteile von #
sind also nicht wirklich überzeugend. Hingegen bietet das private
Keyword einen höheren Komfort für den Entwickler. Zum einen ist es konsistent zu anderen Keywords wie protected
oder public
. Aber noch wesentlicher ist die Möglichkeit der direkten Definition von Klassenattributen via Konstruktor-Parametern:
class MyClass {
constructor(private readonly attr: number) {}
}
Im Gegensatz zu einer weniger kompakten Variante mit #
:
class MyClass {
readonly #attr: number;
constructor(attr: number) {
this.#attr = attr;
}
}
Deshalb empfehle ich, das private
Keyword zu verwenden und der neuen #
-Syntax keine Beachtung zu schenken.
Erkenntnisse
- Dir ist nun klar, dass Typisierung der Keys und Values in
for-in
Schleifen sowieObject.keys()
bewusst von TypeScript nur alsstring
bzw.any
angegeben wird. Jede stärkere Typenannahme ist zwar häufig sinnvoll, aber nicht zwangsläufig korrekt. Durch Komposition statt Vererbung kann man dieser Fehlerquelle zum Beispiel frühzeitig aus dem Weg gehen. - Die Typen
null
,undefined
,void
undnever
sind dir nicht mehr fremd und du kannst die feinen Unterscheidungen treffen. - Du verwendest ab sofort nur das
private
Keyword, statt der ECMAScript#
-Syntax zu folgen.
Ausblick
Im Teil 2: The Bad wirst du unter anderem lernen, wie du Dependency-Zyklen in deiner CI einbaust, wie man enum
s richtig verwendet und wie du dir (nicht) schleichend dein Vertrauen in korrekte Typisierung untergräbst (und damit am Mehrwert von TypeScript gegenüber JavaScript zu zweifeln beginnst).
Fun Fact
Wusstest du, dass die Namensgebung der Beiträge in dieser TypeScript Pitfalls Serie an den Film-Klassiker The Good, the Bad and the Ugly (dt.: „Zwei glorreiche Halunken“) von 1966 angelehnt ist?