Keine Fehler wegen Null oder Undefined mit Typescript
Jeder Javascript Programmierer ist bestimmt schon mal über den Laufzeitfehler
undefined is not a function oder auch Cannot read property ‚length‘ of null gestolpert.
Der Fehler tritt immer dann auf, wenn man mit einem Wert etwas machen möchte (z.B. als Funktion
aufrufen oder auf die length
Property zugreifen), der Wert dann aber undefined
oder
null
ist. Auch Java oder C# Programmier kennen dieses Problem unter dem Name
NullPointerException
oder NullReferenceException
, und in C oder C++ gibt es das Problem
natürlich auch, hier stürzt das Programm gleich ab.
Die Idee einer speziellen null
-Referenz, die überall anstelle einer „echten“ Referenz verwendet
werden kann, geht auf Tony Hoare und ALGOL in den 1960er Jahren zurück. Tony Hoare nannte seine
Erfindung in der Retrospektive den
„billion dollar mistake“,
da die dadurch entstehenden Laufzeitfehler sehr häufig die Ursache von Bugs sind und somit
hohe Kosten verursachen.
Interessanterweise haben statisch getypte, funktionale Programmiersprachen diesen „billion dollar
mistake“ nicht wiederholt. So gibt es z.B. in Haskell den Typ
Maybe
, der die
Abwesenheit eines Werts explizit im Typsystem repräsentiert.
Aber auch in Sprachen wie Javascript hat man die Notwendigkeit erkannt, statisch erkennen
zu wollen, ob Werte null
oder undefined
sein können. So ist in
Typescript, ein statisch getypter Javascript-Dialekt,
seit einiger Zeit die strictNullChecks
Option verfügbar. Wir werden in diesem Artikel sehen,
wie man mit dieser Option Laufzeitfehler vermeiden kann, welche weiteren sinnvollen Optionen
es zur Fehlervermeidung gibt, wie man die strictNullChecks
Option für eine große Codebasis
inkrementell einführen kann und ob man zwischen null
und undefined
unterscheiden sollte.
strictNullChecks
Schauen wir uns folgende Typescript-Funktion an, welche die Zahlen eines Arrays summiert:
function sumArray(arr: number[]): number {
let sum = 0;
arr.forEach(x => {
sum = sum + x;
});
return sum;
}
(Typescript Code sieht so aus wie Javascript Code, mit dem Unterschied dass mittels des Doppelpunkts Typsignaturen definiert werden, die vom Typescript Compiler überprüft werden.)
Der Aufruf sumArray([1,2,3]
liefert dann logischerweise das Ergebnis 6
. Wenn man aber
sumArray(null)
aufruft, kommt es zu einem Laufzeitfehler:
TypeError: Cannot read property 'forEach' of null
Man kann diesen Fehler zur Laufzeit einfach verhindern, indem man dem Typescript-Compiler
die Option --strictNullChecks
mitgibt. Dann bekommt man schon beim Kompilieren den folgenden
Fehler:
$ tsc --strictNullChecks sample.ts
error TS2345: Argument of type 'null' is not assignable to parameter of type 'number[]'.
sumArray(null)
~~~~
Betrachten wir einen weiteren Aufruf, nämlich sumArray([1,2,undefined]
. Wenn man ohne
--strictNullChecks
kompiliert und den Code dann ausführt, erhält man als Ergebnis NaN
, also
„not a number“. Dieses NaN
entsteht durch Addition einer Zahl mit undefined
. Wenn man
aber wieder das --strictNullChecks
Flag verwendet, kommt es zu einem Kompilierfehler:
$ tsc --strictNullChecks sample.ts
error TS2322: Type 'undefined' is not assignable to type 'number'.
sumArray([1,2,undefined])
~~~~~~~~~
Bis jetzt haben wir die Verwendung von null
oder undefined
durch das --strictNullChecks
Flag verboten. Denn mit diesem Flag ist null
oder undefined
nicht mehr Zuweisungkompatibel
zu den Typen number[]
und number
. Was aber wenn die Funktion sumArray
auch
null
oder undefined
als Argument oder als Array-Element unterstützen soll? Mit dem
--strictNullChecks
Flag muss man dies explizit in den Typen modellieren. Das sieht dann so aus:
function sumArray2(arr: (number | null | undefined)[] | null | undefined): number {
let sum = 0;
arr.forEach(x => {
sum = sum + x;
})
return sum;
}
An allen Stellen, an denen man null
oder undefined
erwartet, muss man das entsprechend
im Typ ausdrücken. So ist der Typ (number | null | undefined)[]
ein Array, das Zahlen, null
oder undefined
enthält. Der Typ T[] | null | undefined
ist dann ein Array mit T
als
Elementtyp, oder null
oder undefined
. (Wir werden am Schluss des Artikels sehen,
wie man diese doch etwas längliche Schreibweise kürzer bekommt, indem man nur einen der beiden
Werte null
und undefined
in seinen Programmen verwendet.)
Wenn man nun aber sumArray2
mit --strictNullChecks
kompiliert, bekommt man neue Kompilierfehler,
denn schließlich verwenden wir im Rumpf den Parameter arr
und die Array-Elemente ohne
zu Bedenken, dass sie null
oder undefined
sein könnten:
tsc --strictNullChecks sample.ts
error TS2533: Object is possibly 'null' or 'undefined'.
arr.forEach(x => {
~~~
error TS2533: Object is possibly 'null' or 'undefined'.
sum = sum + x;
~
Man behebt diese Kompilierfehler z.B. so:
function sumArray2(arr: (number | null | undefined)[] | null | undefined): number {
if (!arr) {
return 0;
}
let sum = 0;
arr.forEach(x => {
sum = sum + ((x === null || x === undefined) ? 0 : x);
})
return sum;
}
Durch eine Kontrollflussanalyse weiß der Typescript-Compiler, dass nach dem if
die Variable
arr
sicher ein Array ist und dass für die Addition der Wert der Variable x
nur dann
verwendet wird, wenn der Wert eine Zahl ist. Damit liefert dann
sumArray2([1,2,undefined])
das Ergebnis 3
und sumArray2(null)
liefert 0
.
Weitere sinnvolle Optionen zur Vermeidung von Laufzeitfehlern
Es gibt in Typescript eine ganze Reihe weiterer Compiler-Optionen zur Vermeidung von Laufzeitfehlern. Eine vollständige Liste ist hier zu finden. Man kann diese Optionen entweder auf der Kommandozeile oder in der tsconfig.json Konfigurationsdatei angeben.
Die meiner Meinung nach wichtigsten Optionen zur Vermeidung von Laufzeitfehlern sind:
--alwaysStrict
--noFallthroughCasesInSwitch
--noEmitOnError
--noImplicitAny
--noImplicitReturns
--noImplicitThis
--strictBindCallApply
--strictFunctionTypes
--strictPropertyInitialization
--strictNullChecks
Man kann diese Liste kürzer schreiben, indem man das Metaflag --strict
und
--noImplicitReturns
, --noEmitOnError
und --noFallthroughCasesInSwitch
verwendet.
Neben dem --strictNullChecks
Flag ist inbesondere --strictFunctionTypes
wichtig,
denn es sorgt dafür, dass sich Funktionen gemäß Subtyping vernünftig verhalten. Dazu ist
es aber wichtig zu wissen, dass Typescript hier eine Unterscheidung zwischen
Methoden- und Funktionssyntax macht. Betrachten wir dazu folgendes Beispiel:
interface Name {
firstName: string;
lastName: string;
}
interface Formatter {
// Important: use function syntax!
formatName: (name: Name | undefined) => string | undefined
}
function format(fmt: Formatter) {
fmt.formatName(undefined);
}
const myFormatter = {
formatName(name: Name): string {
return name.firstName + " " + name.lastName;
}
}
format(myFormatter);
Wenn man den Code ohne --strict
kompiliert, gibt es erst zur Laufzeit einen Fehler,
den myFormatter
kann, anders als im Interface Formatter
vorgesehen, mit dem Argument
undefined
nicht umgehen. Mit dem Flag --strict
gibt es aber für den Ausdruck
format(myFormatter)
diesen Kompilierfehler:
Type '(name: Name) => string' is not assignable to type '(name: Name | undefined) => string | undefined'.
Gemäß den üblichen Subtypregeln für Funktionen ist dieser Fehler auch richtig, denn eine Funktion
vom Typ U => V
ist nur genau dann ein Subtyp einer Funktion vom Typ S => T
,
wenn V
ein Subtyp von T
ist (covariant im Ergebnis) und S
ein Subtyp von U
ist (contravariant im Argument). Dies ist hier nicht der Fall, denn Name | undefined
ist kein Subtyp von Name
.
Wichtig ist hierbei allerdings, dass dieser Check nur greift wenn --strict
(bzw. --strictFunctionTypes
) aktiv ist und wir außerdem Funktionssyntax
für den Typ der formatName
Property im Formatter
benutzen. Schreibt man Formatter
mit
Methodensyntax
interface Formatter {
formatName(name: Name | undefined): string | undefined
}
dann gibt es auch mit dem --strict
Flag keinen Kompilier- sondern einen Laufzeitfehler.
strictNullChecks inkrementell einführen
Unser Produkt Checkpad hat nicht nur ca. 300.000 Zeilen Haskell Code sondern inzwischen auch mehr als 200.000 Zeilen Typescript Code. Wir haben dort alle oben erwähnten Compiler-Flags aktiviert. Allerdings gab es zu Beginn der Einführung von Typescript in unserem Produkt die meisten der Flags noch gar nicht, weshalb wir einen inkrementellen Weg zur Einführung finden mussten.
Manche Flags kann man einfach direkt für die ganze Codebasis anschalten, z.B. hat das
bei --noFallthroughCasesInSwitch
oder --noImplicitReturns
funktioniert. Aber insbesondere
das Flag --strictNullChecks
war unmöglich auf einmal einzuführen, weil es dadurch
zu sehr vielen Kompilierfehlern gekommen wäre, die man unmöglich auf einmal fixen kann
(es sei denn man möchte für geschätzt zwei Woche die Entwicklung komplett einstellen).
Wir haben daher ein neues Kompiliertarget zur Einführung dieser Checks angelegt. Dieses
Kompiliertarget hat eine Einstiegsdatei strictNullChecks.ts
. Diese Datei importiert
die Module, welche schon mittels --strictNullChecks
kompiliert werden können. Um
es möglichst einfach zu machen, inkrementell neue Module zu diesem Target hinzuzufügen
haben wir initial in strictNullChecks.ts
alle Module in auskommentierter Form importiert
und dafür gesorgt, dass Module mit wenigen Abhängigkeiten weiter oben stehen. Im Verlauf
der Einführung von --strictNullChecks
hatte dann die strictNullChecks.ts
Datei z.B.
so ausgesehen:
import "checkpad/util/array";
import "checkpad/util/datetime";
import "checkpad/util/eq";
import "checkpad/util/findefset";
import "checkpad/util/option";
import "checkpad/util/hashable";
import "checkpad/util/lazy";
import "checkpad/util/types";
import "checkpad/util/unit";
import "checkpad/util/uuid";
// import "checkpad/util/linked-list";
// import "checkpad/util/map";
// import "checkpad/util/cache";
// import "checkpad/util/color";
// import "checkpad/util/md5";
// ... viele weitere imports
Wenn man nun ein weiteres Module zu den --strictNullChecks
dazu nehmen will, entfernt
man einfach den Kommentar beim obersten auskommentierten Import
(hier: checkpad/util/linked-list
), fixt die Kompilierfehler
und ist mit diesem Modul fertig. Dadurch, dass Module mit wenigen Abhängigkeiten weiter oben
stehen, muss man immer nur die Fehler in dem Modul, welches man gerade hinzugefügt hat, fixen.
Die topologische Sortierung der Module gemäß ihrer Imports haben wir initial einmal mittels
depcruise berechnet. Für die
Einführung der strict
Flags haben wir mit diesem inkrementelle Ansatz für 200.000 Zeilen
Typescript-Code effektiv ca. 5 Monate gebraucht.
null oder undefined
In Javascript und damit auch in Typescript gibt es mit null
und undefined
zwei Konzepte,
um die Abwesenheit eines Werts zu kodieren. Die beiden Konzepte sind von der Semantik
leicht unterschiedlich, aber für die tägliche Arbeit quasi austauschbar. Wir haben
bei den Typsignaturen weiter oben gesehen, dass es etwas unhandlich ist, immer null
und undefined
zu unterstützen.
Daher ist es prinzipiell wünschenswert, in einem Projekt entweder nur null
oder nur undefined
zu verwenden.
Douglas Crockford
argumentiert, dass undefined
das Konzept sein sollte, welches man verwendet. Der Grund hierfür ist
simpel: undefined
ist in Javascript bzw. Typescript unvermeidbar, so sind z.B.
optionale Argument und Properties mittels undefined modelliert, die Standardfunktion
find
auf Arrays liefert undefined
wenn das Element nicht gefunden werden kann und
der Array-Zugriff mit ungültigem Index liefert ebenfalls undefined
.
Wir werden daher auch in unserem Codebasis null
durch undefined
ersetzen. Dieser
Prozess ist aber noch nicht abgeschlossen. Bei dieser Umstellung ist es aber wichtig
das Flag --noImplicitReturns
zu aktivieren, damit man Fälle entdeckt, in denen man
durch Weglassen eines return
Statements aus Versehen undefined
als Ergebnis
zurückliefert.