In diesem Teil der Serie TypeScript Pitfalls zeige ich dir einige konkrete Kritikpunkte, die ich an TypeScript zu bemängeln habe. Zudem gehe ich auf schwerwiegende Fallen ein, in die viele Entwickler im Umgang mit TypeScript geraten, wodurch häufig das Vertrauen in die Sprache an sich untergraben wird.
Konfiguration
TypeScript ist eine Super-Sprache von JavaScript, sodass jede valide JavaScript-Datei auch eine valide TypeScript-Datei ist. Dies war ein wesentlicher Punkt, um TypeScript zu einer populären Sprache zu machen. Allerdings ist die Situation heute so, dass viele Projekte direkt mit TypeScript starten und immer weniger Projekte müssen von JavaScript nach TypeScript portiert werden. Dass die Standards von TypeScript allerdings nach wie vor der schwächsten Konfigurationsmöglichkeit von TypeScript entspricht, die einem Projekt die geringste Sicherheit bietet, halte ich für eine grobe Nachlässigkeit. Ein Opt-in zu absoluter JavaScript-Kompatibilität wäre jedenfalls zeitgemäßer. Microsoft hätte längst mit einem der vielen Major-Updates der Sprache auch eine Anpassung der Standardkonfiguration vornehmen können. Dann könnten neue Entwickler von Anfang an auf die Sicherheit ihrer Typisierung vertrauen. Stattdessen werden sie möglicherweise anfangs mit einer Variante der Sprache vertraut, die wenig Sicherheit und keine Vertrauensbasis bietet und dennoch einen deutlichen Mehraufwand im Vergleich zu JavaScript fordert. Dass dies der Community schadet und ein falsches Bild von TypeScript liefert, ist für mich eindeutig.
Viele TypeScript-Entwickler wissen natürlich bereits, worüber ich rede. Aber hier noch einmal ganz deutlich:
// tsconfig.json
{
"compilerOptions": {
"strict": true,
// ...
}
}
Kurz gesagt kann ich Projekte, in denen der strict
Modus nicht aktiviert ist, nicht guten Gewissens als TypeScript-Projekte bezeichnen.
Dependency-Zyklen
In fast jeder Sprache kennt man das Problem der Dependency-Zyklen von Dateien. Während aber die meisten Sprachen bei solchen Zyklen einen Fehler beim Kompilieren werfen, sind JavaScript und TypeScript nicht so streng. In der Regel möchten Entwickler aber keine Architektur, in der Zyklen vorkommen dürfen, auch wenn diese durch die Sprache erlaubt werden und definiertes Verhalten darstellen.
Mit TypeScript wird das Problem zweigeteilt, da import
s entweder Laufzeit- oder Compilezeit-Abhängigkeiten sein können; reine type import
s sind also nicht im Kompilat enthalten. Hierzu ist meine klare Empfehlung, dass Laufzeit-Zyklen vermieden werden sollten. Eine Vermeidung von Compilezeit-Zyklen hingegen sehe ich als unnötigen Aufwand, der zu komplexeren Strukturen führt, ohne einen realen Mehrwert zu bieten.
In jedem Fall gibt es ein Tool, das hier Abhilfe schafft: dpdm
Mit dpdm kann in einem Projekt einfach in die CI-Pipeline ein Befehl wie dpdm -T --exit-code circular:1 […entrypoint-files]
eingebaut werden, um Laufzeit-Zyklen zu verweigern. Für Compilezeit-Zyklen (je nach Bedarf) einfach den -T
Parameter weglassen.
Fehlerfall-Typisierung
Wir kennen aus der Java-Welt das Keyword throws
, das eine Funktionssignatur um ihre Fehlerfälle erweitert. Enttäuschenderweise gibt es in TypeScript keine entsprechende Möglichkeit, eine Fehlertypisierung vorzunehmen. Auch der Promise<T>
Typ bietet keinen zweiten generischen Parameter, um den Fehlerfall zu typisieren. Zudem kann man in try {} catch (err) {}
Konstrukten err
ausschließlich mit any
oder unknown
typisieren.
Eine Möglichkeit ist es, ein Paket wie ts-results zu verwenden, bei dem der native Fehlerfall der Sprache umgangen wird. Aber das geht leider mit einem erheblichen Komfort-Verlust an diversen Stellen einher.
Meine Empfehlung ist daher, in der Regel bei den nativen Mitteln von TypeScript zu bleiben und mit der fehlenden Typensicherheit zu leben. In Catch-Klauseln sollte man den Fehler auf bekannte Klassen prüfen und nur diese verarbeiten. Alle Fehler von unbekannter Klasse direkt weiter durchreichen.
try { /* ... */ } catch (err) {
if (!(err instanceof MyError)) { throw err; }
// ...
}
Kommen wir nun wieder zu Themen, die definitiv in der Hand der Entwickler liegen!
String Literal Union Typen
In vielen Projekten begegnen mir wieder und wieder Union-Typen von String Literalen, wie z. B.
export function changeDirection(direction: "top" | "left" | "bottom" | "right") {
// ...
}
Diese quick&dirty Typisierung sorgt für schlechtere Unterstützungsmöglichkeiten durch die IDE. Man kann zum Beispiel nicht vernünftig nach Verwendungen von "left"
suchen, oder alle Stellen im Code finden, bei denen eine direction
instanziiert wird. Auch Umbenennungen der Werte sind nicht einfach möglich.
All diese Nachteile hat eine Typisierung mittels eines enum
Typen nicht. Deshalb empfehle ich im Allgemeinen, statt String Literal Union Typen lieber enum
s zu verwenden. Der Fakt, dass Enums auch zur Laufzeit existieren, sollte in nahezu allen Fällen vernachlässigbar sein. Damit kommen wir auch direkt zum nächsten Punkt …
Enums
TypeScript behandelt Enums unterschiedlich, je nachdem, ob die Werte vom Typen number
oder string
sind:
enum MyEnumNumbers {
A, // (implicit '= 0')
B, // (implicit '= 1')
// { "A":0, "0":"A", "B":1, "1":"B" }
}
enum MyEnumMixed {
A = "A_VALUE",
B = 1,
// { "A":"A_VALUE", "B":1, "1":"B" }
}
enum MyEnumStrings {
A = "A_VALUE",
B = "B_VALUE",
// { "A":"A_VALUE", "B":"B_VALUE" }
}
Bei number
Werten wird zusätzlich zu MyEnumNumber["A"]
auch MyEnumNumber[0]
verfügbar gemacht. Das halte ich für groben Unsinn. Es verursacht unerwartete Dinge bei Statements wie Object.values(MyEnumNumber)
, wo man eine Liste genau aller Werte vom Enum erwartet.
Aus diesem Grund ist meine allgemeine Empfehlung, auf Enums mit Zahlenwerten zu verzichten und immer String-Werte zu definieren. Üblicherweise können und sollten Key und Value auch gleich sein.
any
und unknown
TypeScript bietet zwei Typen, für die alle Werte valide sind: any
sowie unknown
Sie unterscheiden sich in ihrer Verwendung. Während jede Verwendung (Attribut-Zugriffe, Funktionsaufrufe, etc.) von unknown
in Fehlern resultiert, ist eine Verwendung von any
zulässig und resultiert ebenfalls wieder in any
. Somit ist any
im Grunde eine Aushebelung jeglicher Typensicherheit, während unknown
sich innerhalb des Rahmens der Typensicherheit bewegt. Aus diesem Grund sollte any
so wenig wie möglich verwendet werden. Eine API, die any
beinhaltet, ist nicht gut definiert. Wenn innerhalb eines Moduls any
verwendet wird, dann sollte ein rechtfertigender Kommentar vorhanden sein. Ich sehe zum Beispiel als guten Grund an, dass in Ausnahmefällen die Korrektheit vom Code leicht nachzuvollziehen ist, während eine korrekte Typisierung sehr aufwändig ist.
Dass TypeScript selbst im strict
Modus (siehe oben) any
an manchen Stellen implizit einschleust, halte ich für einen Design-Fehler. So muss zum Beispiel bei JSON.parse(...)
aufgepasst werden und das Ergebnis sollte unmittelbar zum entsprechend spezifischen Typen angenommen werden (z. B. unknown
, wenn es erst validiert werden muss). Ein JSON.parse(...)
, das ohne as ...
steht, zeugt von Nachlässigkeit und hebelt das Typensystem aus. Ebenso jede API, die any
beinhaltet.
Type Assertions
Das as
Keyword habe ich gerade genannt. Daneben gibt es zudem die someExpression!
Syntax, die ebenfalls eine Type-Assertion darstellt. Type-Assertions sind Annahmen, die der Entwickler im Code verankert, ohne dass das Typensystem diese verifiziert. Das bedeutet, dass Type-Assertions, ebenso wie any
, das Typensystem aushebeln. Deshalb ist damit auf jeden Fall sehr spärlich und sorgsam umzugehen. In jedem Fall ist eine Erläuterung in einem Kommentar sinnvoll, warum der Entwickler sich für schlauer als der Compiler hält. Obigen Fall von JSON.parse
und andere Fälle, in denen man sich von any
weg bewegt einmal außen vor gelassen.
Auf der ewigen Suche nach Metriken für guten Code schlage ich die Menge an Type-Assertions sowie Operationen auf Variablen vom Typ any
vor 😅
Erkenntnisse
Damit sind wir auch schon am Ende dieses Beitrags angelangt. Folgende Punkte solltest du mitgenommen haben:
- Du wirst in jedem TypeScript Projekt als Erstes den
aktivieren.strict
Modus - dpdm hast du als nützliches Tool für die Analyse von Dependency-Zyklen kennengelernt.
- Du verzichtest fortan auf String Literal Unions zugunsten von Enums bei allen APIs, um den Entwicklungskomfort hochzuhalten.
- Für Enums verwendest du String-Werte, damit das Enum sich in allen Hinsichten intuitiv verhält. Dies ist insbesondere für
Object.values
, etc. relevant. - Nahezu jedes Vorkommen von
any
oder Type-Assertions im Code sollte vermieden, oder mindestens explizit in einem Kommentar gerechtfertigt werden.
Ausblick
Im dritten und letzten Teil dieser Serie The Ugly wirst du einen letzten Fallstrick von TypeScript kennenlernen, den es zu vermeiden gibt. Außerdem zeige ich dir zwei Fehler, die du im Hinterkopf behalten solltest, da sie besonders schwierig zu debuggen sind und die Fehlermeldungen nicht aussagekräftig sind. Einer davon berührt eine Limitierung von TypeScript selbst, die gerne speziell fortgeschrittene Entwickler trifft und zu unnötigen Stunden oder sogar Tagen an Fehlersuche führen kann.