TypeScript Pitfalls — Teil 1: The Good

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][]
}

(playground)

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");

(playground)

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 sowie Object.keys() bewusst von TypeScript nur als string 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 und never 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 enums 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?

Teilen Sie diesen Beitrag

Das könnte dich auch interessieren …

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert