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.