TypeScript Pitfalls — Teil 3: The Ugly

Dies ist der letzte Teil der Serie TypeScript Pitfalls. Ich zeige dir hier drei Fallstricke, die besonders schwierig zu debuggen sind, oder bereits früh in der Entwicklung vermieden werden sollten.

Globale Typendefinitionen

Es gibt viele Gründe dafür, für den globalen Scope neue Variablen zu definieren. An sich ist das in TypeScript auch nicht weiter schwierig: Man erstellt einfach eine .d.ts Datei, in der man den globalen Scope erweitert:

declare global {
    const someGlobalVariable: string;
}

Doch irgendwie scheint das nicht zu greifen. Nach dem Grund dafür kann man lange suchen … Die Lösung hingegen ist ein Einzeiler und schnell gezeigt:

declare global {
    const someGlobalVariable: string;
}

export {};

Es bedarf eines leeren export Statements, damit TypeScript die Datei als ECMAScript-Modul versteht (anstatt als CommonJs-Datei). Ein subtiler Fehler, der gerne mal untergeht, während man ewig in der tsconfig.json nach einer Lösung sucht.

Ebenfalls zu ewiger Suche nach dem Fehler führt das folgende Problem.

25 ist das Limit

TypeScript ist mächtig und hat ein komplexes Typensystem, mit dem man viele Dinge anstellen kann. Beeindruckt von der schieren Grenzenlosigkeit zweifelt man deshalb bei Fehlern eher an der eigenen Logik, als das Problem beim Compiler zu suchen. Dies kann insbesondere bei einem klaren Limit in TypeScript schnell zu stundenlanger Frustration führen, zudem die Fehlermeldung oft lange Werteketten beinhaltet und aussieht wie eine übliche Typeninkompatibilität:

Argument of type '{ val: Union1 | Union2; }' is not assignable to parameter of type '{ val: Union1; } | { val: Union2; }'.
    Type '{ val: Union1 | Union2; }' is not assignable to type '{ val: Union2; }'.
        Types of property 'val' are incompatible.
            Type 'Union1 | Union2' is not assignable to type 'Union2'.
                Type '"A"' is not assignable to type 'Union2'.

Dieser Fehler wird von folgendem Code verursacht:

type Union1 = 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H' | 'I' | 'J' | 'K' | 'L' | 'M'
type Union2 = 'N' | 'O' | 'P' | 'Q' | 'R' | 'S' | 'T' | 'U' | 'V' | 'W' | 'X' | 'Y' | 'Z'

function one(it: { val: Union1 } | { val: Union2}) {
  two(it); // no compile error
}

function two(it : { val: Union1 | Union2 }) {
  one(it); // compile error
}

(playground)

Doch entfernt man ein beliebiges String Literal aus Union1 oder Union2, verschwindet der Fehler. Aus den entsprechenden github issues geht hervor, dass dies ein Design-Limit von TypeScript ist und somit wohl auch in Zukunft fortbestehen wird.

Anzumerken ist, dass im Allgemeinen Union Typen von mehr als 25 Einträgen in TypeScript durchaus verwendet werden können. Nur dieser spezielle Transformation-Typ trifft an die Grenze der Sprache. Ein typensicherer Weg um das Problem herum ist mir nicht bekannt, somit muss hier wohl leider mit any ausgeholfen werden.

Ternäre Lookup-Typendefinitionen

Kommen wir nun zu einem der Konstrukte, die mir im Laufe meiner Zeit als Webentwickler am meisten Kopfzerbrechen bereitet haben: die exzessive Nutzung ternärer Operatoren bei Typendefinitionen.

Meiner Erfahrung nach funktioniert das Programmieren in einer Sprache besser, wenn man sich auch konzeptuell an die Sprache anpasst. Stattdessen sehe des Öfteren, dass genutzte Konzepte aus anderen Sprachen zu stammen scheinen. Dies gilt auch und insbesondere für die Programmierung in TypeScript im Kontrast zu JavaScript. Nicht jedes Konstrukt, das in JavaScript gut zu verwenden ist, lässt sich 1:1 auf TypeScript übertragen. Die oben genannten ternären Operatoren als Typen-Mapping halte ich für einen solchen Fallstrick.

Nehmen wir folgendes Beispiel:

enum FileType {
    MARKDOWN = "MARKDOWN",
    PLAIN_TEXT = "PLAIN_TEXT",
}

interface FileBase<T extends FileType> {
    type: T;
    content: string;
}
interface MarkdownFile extends FileBase<FileType.MARKDOWN> {
    parsedHtml: string;
}
interface PlainTextFile extends FileBase<FileType.PLAIN_TEXT> {}

type MyFile<T extends FileType> =
    T extends FileType.MARKDOWN
      ? MarkdownFile
      : T extends FileType.PLAIN_TEXT
      ? PlainTextFile
      : never;

function getFilesByType<T extends FileType>(type: T): MyFile<T>[] {
    switch (type) {
        case FileType.MARKDOWN:
            return getMarkdownFiles(); // compile error
        case FileType.PLAIN_TEXT:
            return getPlainTextFiles(); // compile error
        default:
            throw new Error(`Unknown File Type: ${type}`);
    }
}

function getMarkdownFiles(): MarkdownFile[] {
    return [/* ... */];
}

function getPlainTextFiles(): PlainTextFile[] {
    return [/* ... */];
}

(playground)

Der Gedanke, nach dem hier gehandelt wurde, ist relativ einfach nachzuvollziehen: Wir brauchen eine Funktion getFilesByType, die abhängig vom Dateitypen eine Liste aller entsprechenden Dateien (konkret typisiert) zurückgibt. Hier wurde deshalb versucht, dies über die Typisierung des Funktionsheaders zu erreichen. Der Funktionsheader wurde auch genau so typisiert, wie wir es von der Funktion wollen. Doch TypeScript versteht nicht, dass anhand einer Inferenz von type: FileType.MARKDOWN auch folgt, dass MyFile<T>: MarkdownFile gelten muss. Viele Entwickler würden nun wohl zum berüchtigten as any greifen, um das Problem verschwinden zu lassen und korrumpieren damit die Stabilität des Programmes. Zusätzlich erfordert das obige switch-Statement einen default Zweig, obwohl wir theoretisch bereits alle Möglichkeiten abgedeckt haben.

Ein Alternativvorschlag von mir sieht wie folgt aus:

const filesByType: { [Key in FileType]: () => MyFile<Key>[] } = {
    [FileType.MARKDOWN]: getMarkdownFiles,
    [FileType.PLAIN_TEXT]: getPlainTextFiles,
}

function getFilesByType<T extends FileType>(type: T): MyFile<T>[] {
    return filesByType[type]();
}

(playground)

Auf diese Art kann das Problem umgangen werden und wir sehen, dass auch der Funktionsheader gleich geblieben ist 🎉

Dies ist noch ein verhältnismäßig einfaches Beispiel; in komplexeren Fällen — insbesondere, sobald andere generische Parameter mit ins Spiel kommen — lässt sich häufig keine so einfache Lösung mehr finden.

Glücklicherweise gibt einen Weg, die ganze Problematik von vornherein zu umgehen:

type MyFile = MarkdownFile | PlainTextFile;

function getFilesByType(type: FileType): MyFile[] {
    switch (type) {
        case FileType.MARKDOWN:
            return getMarkdownFiles();
        case FileType.PLAIN_TEXT:
            return getPlainTextFiles();
    }
}

(playground)

Verzichten wir also auf die ternäre Lookup-Typendefinition und verwenden stattdessen einen einfachen Union-Typen, vereinfacht dies den Code wesentlich. Besonders in komplexeren Szenarien erspart dies unnötige Komplexität ein. Selbst in dem einfachen Beispiel hier sehen wir bereits, dass der default Zweig entfällt und wir somit auch eine funktionierende Vollständigkeit-Validierung haben.

Der spezifische Typ des Return-Wertes kann bei Bedarf nach wie vor (zur Laufzeit) durch den type Union-Tag identifiziert (und einfach inferiert) werden. Auch kann hier je nach Präferenz natürlich analog zu oben mit einem filesByType-Objekt gearbeitet werden, falls man switch-Statements vermeiden möchte.

Im Allgemeinen funktioniert diese Vereinfachung, wenn außerhalb der Funktion keine spezifischere Typisierung auf Basis des Parameters notwendig ist. Dies ist generell der Fall, wenn der Typ des Parameters beim Aufruf bereits unspezifisch ist. Ist der Parametertyp allerdings spezifisch, dann kann man auch direkt die zugehörige spezifische Funktion getMarkdownFiles, bzw. getPlainTextFiles verwenden.

Damit ist also in jedem Fall eine alternative Herangehensweise möglich, die auf eine Verwendung von ternären Lookup-Typendefinitionen verzichtet und stattdessen mit dem einfacheren Union-Typen Konzept auskommt. Ich empfehle dir, diese Alternativen bereits frühzeitig in Betracht zu ziehen, bevor die ternären Lookups die Code-Komplexität im Projekt großflächig erhöhen.

Erkenntnisse

In diesem Abschluss der Reihe von Typescript Pitfalls hast du gelernt, dass ein leeres export {}; in manchen .d.ts-Dateien erforderlich ist. Zudem weißt du nun, dass mit der 26. Komponente eines Union-Typen eine TypeScript-Limitierung erreicht wird, deren Fehlermeldung in sich weder schlüssig ist, noch auf die angetroffene Grenze hinweist. Vermutlich erspart dir diese Erkenntnis eines Tages viele Stunden verwirrten Debuggings. Zuletzt habe ich dir eine Empfehlung mit klaren Handlungsschritten mitgegeben, um dich vor unnötiger Komplexität durch ternäre Lookup-Typen zu bewahren.

Abschluss

Da du bis zum Ende gelesen hast, nehme ich an, dass du ein paar für dich neue Dinge mitgenommen hast. Ich bin zuversichtlich, dass diese dir den Umgang mit TypeScript erleichtern werden und Verständnis für die Sprache stärken. Du bist jetzt besser gerüstet, ein paar konkrete Fallstricke direkt in den Ansätzen zu vermeiden! Nicht jeden Fehler muss man selbst begehen 😉

Berichte mir gerne in den Kommentaren, welche Punkte du besonders hilfreich fandest und ob du weiteren Fallstricken von TypeScript begegnet bist. Lasst uns alle gemeinsam besseren Code schreiben!

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

Durch die weitere Nutzung der Seite stimmen Sie der Verwendung von Cookies zu. Weitere Informationen

Die Cookie-Einstellungen auf dieser Website sind auf "Cookies zulassen" eingestellt, um das beste Surferlebnis zu ermöglichen. Wenn du diese Website ohne Änderung der Cookie-Einstellungen verwendest oder auf "Akzeptieren" klickst, erklärst du sich damit einverstanden.

Schließen