Typsicher und komfortabel mit TypeScript
Seite 2: Typsicheres Arbeiten mit eigenen Objekten
Anders als in Java und C# spielen Klassen in JavaScript eine untergeordnete Rolle. In der Regel arbeiten Entwicklerinnen und Entwickler stattdessen einfach mit Objekten, die zur Laufzeit nur den allgemeinen Typ object
haben. TypeScript erlaubt, die Struktur eines Objektes zu beschreiben, um Anforderungen daran auszudrücken. Dazu dient entweder type
oder interface
als Schlüsselwort. Die Unterschiede zwischen diesen beiden Konzepten sind marginal und anfangs zu vernachlässigen. Die type
- beziehungsweise interface
-Definition legt die Properties eines Objektes und deren Typen fest. Außerdem lässt sich angeben, ob eine Property in dem Objekt optional und/oder readonly ist.
Folgender Codeausschnitt zeigt die Definition eines Person
-Objekts, das aus firstname
, lastname
und age
besteht, wobei letztere Property durch das Fragezeichen als optional gekennzeichnet ist. Anschließend erzeugt der Code eine Variable, die ein Objekt des Person
-Typs annehmen soll.
type Person = {
firstname: string;
lastname: string;
age?: number;
}
let klaus: Person = {
firstname: "Klaus",
lastname: "Mueller",
age: 32
} // OK
let susi = {
firstname: "Susi",
lastname: "Meier"
};
let p: Person = susi; // OK
Der zweite Teil weist einer Variable susi
ein Objekt zu, das strukturell dem Person
-Typ entspricht. Daher ist es in der letzten Zeile erlaubt, die Variable susi
an die Variable p zu übergeben, die explizit vom Typ Person
ist. An dieser Stelle vergleicht TypeScript den für susi
ermittelten mit dem für p
erwarteten Typ. Da der abgeleitete Typ von susi
strukturell Person
entspricht, erlaubt TypeScript die Zuweisung. Dasselbe würde passieren, wenn es zwei Objekttypen unterschiedlichen Namens gäbe, die zueinander kompatibel sind. Damit ließe sich einer Variablen, die explizit vom Typ Person
ist, ein Wert zuweisen, der beispielsweise von einem kompatiblen Typ Employee
ist. Dieses sogenannte Structural Typing heißt umgangssprachlich Duck Typing: Wenn etwas aussieht wie eine Ente (Duck) und sich verhält wie eine Ente, ist es vermutlich eine Ente – unabhängig davon, ob es ein Schild mit der Aufschrift "Ente" trägt oder nicht. Java und C# verwenden hingegen das sogenannte Nominal Typing, bei dem zwei Typen immer unterschiedlich sind, wenn ihre qualifizierten Namen voneinander abweichen.
Typprüfung
In JavaScript lassen sich Funktionen anders als in objektorientierten Sprachen wie Java oder C# nicht überladen: Eine Funktion gibt es nur einmal. Das ist ein Grund, warum Funktionen mehrere Typen für ein Argument akzeptieren und zur Laufzeit prüfen, welchen Typen sie übergeben bekommen haben, um jeweils den korrekten Code auszuführen. Folgender Code zeigt die erweiterte greet
-Funktion, die im Vergleich zum vorherigen Listing zusätzlich zum string
ein Person
-Objekt entgegennimmt.
function greet(person: string | Person) {
// person ist hier string | Person
console.log(person.toUpperCase());
// Fehler: Property 'toUpperCase' does not exist
// on type 'string | Person'.
if (typeof person === "string") {
// person ist hier string
return "Hello, " + person.toUpperCase();
}
// Person ist hier Person
return "Hello, " + person.lastname.toUpperCase()
}
Die Funktion prüft zur Laufzeit mit dem JavaScript-Operator typeof
, welchen der beiden Typen sie übergeben bekommen hat und führt den passenden Code aus. Das Beispiel zeigt ein weiteres Feature von TypeScript: Die Sprache interpretiert den Code, der zur Laufzeit die Typprüfungen beispielsweise mit typeof
durchführt und zieht daraus bereits zur Entwicklungszeit Rückschlüsse auf die Typen. Daher ändert sich der Typ des person
-Parameters in der greet
-Funktion. In der ersten Zeile ist der Typ string
oder Person
. Vor dem if
-Block könnten daher faktisch keine Operationen auf der Variablen aufgerufen werden, da string
und das Person
-Objekt bis auf einige Default-Methoden keine Gemeinsamkeiten haben. Insbesondere hat das Objekt keine toUpperCase
-Methode, die sich aufrufen lässt, und der String bietet keine lastName
-Property, die für den Gruß im Falle des Objekts zum Einsatz kommt. Die Folge wäre somit ein Compile-Fehler.
Innerhalb des if
-Blocks leitet TypeScript den Typ jedoch aus der typeof
-Prüfung als string
ab. Daher lässt sich die toUpperCase
-Funktion verwenden. Da der Programmablauf die Funktion innerhalb des if
-Blocks über return
verlässt, kann TypeScript für den Bereich danach davon ausgehen, dass der Typ Person
sein muss. Die Vorgehensweise heißt Type Narrowing, also etwa Typverengung: TypeScript schränkt eine Menge von Typen, die in einem Union-Typ zusammengefasst sind, durch unterschiedliche Prüfungen ein.
Die if
-Prüfung mit typeof
wird in TypeScript als Type Guard bezeichnet, weil sie als eine Art Wächter dient, der nur bestimmte Typen durchlässt. Zu den weiteren Type Guards zählt unter anderem die Prüfung auf null
. Folgender Codeausschnitt erweitert die greet
-Funktion, sodass sie zusätzlich null
akzeptiert. Ohne die zusätzliche Prüfung gäbe es einen Fehler, da der Typ vor dem letzten return
-Statement Person
oder null
wäre.
function greet(person: string | Person | null) {
// person ist hier string | Person | null
if (person === null) {
// person ist hier null
return "";
}
if (typeof person === "string") {
// person ist hier string
return "Hello, " + person.toUpperCase();
}
// person ist hier Person
return "Hello, " + person.lastname.toUpperCase()
}
Objekte unterscheiden
Eine Sonderform bilden die sogenannten Tagged Union Types. Dabei bestehen alle einzelnen Typen aus Objekten, die sich über den Wert einer Property unterscheiden. Folgender Code zeigt zwei action
-Objekte, die als Gemeinsamkeit eine name
-Property haben. Deren Wert ist für die beiden Objekte festgelegt, sodass nur einer der beiden Strings erlaubt ist (siehe Abbildung 2). Da die Property in beiden Funktionen vorhanden ist, kann vor dem Verwenden wie in der handleAction
-Funktion eine Prüfung darauf erfolgen. Obwohl sie erst zur Laufzeit erfolgt, kann TypeScript daraus Rückschlüsse auf den Typ in den jeweiligen case
-Zweigen schließen und die korrekte Verwendung überprüfen.
type VerifyIbanAction = {
name: "VerifyIban";
iban: string;
}
type VerifyAgeAction = {
name: "VerifyAge";
age: number;
}
function handleAction(action: VerifyIbanAction
| VerifyAgeAction) {
switch (action.name) {
case "VerifyIban":
// action ist hier VerifyIbanAction
return verifyIban(action.iban);
case "VerifyAge":
// action ist hier VerifyAgeAction
return verifyAge(action.age); }
}
function verifyIban(iban: string) { /* ... */ }
function verifyAge(age: number) { /* ... */ }
Sag niemals nie
Spannend ist, was mit dem Typ von action
passiert, wenn der Code unvorhergesehen hinter das switch
-Statement gelangt. Die beiden Treffer auf die Property verlassen jeweils die Funktion. Gemäß der Typdefinition dürfte das Programm niemals den Code hinter dem switch
-Statement erreichen. Allerdings führt TypeScript die Typüberprüfung lediglich zur Build-Zeit durch, was in den meisten Fällen ausreicht.
TypeScript kann das Verwenden der Typen beziehungsweise die Aufrufe der handleAction
-Funktion in der ganzen Anwendung überprüfen, sodass der korrekte Aufruf der Funktion wahrscheinlich ist. Wenn eine Anwendung die übergebenen Objekte jedoch nicht statisch erzeugt, sondern beispielsweise die Antwort auf eine Anfrage über HTTP von einer REST API verwendet, kann es durchaus vorkommen, dass die Objekte nicht der geforderten Struktur entsprechen. In dem Fall würde der Programmfluss keinen der beiden case
-Blöcke durchlaufen und die Funktion somit nicht planmäßig über return
verlassen. Da dieser Fall aus TypeScript-Sicht niemals auftreten kann, heißt der Typ, den action
an der Stelle hat, folgerichtig never
. Es handelt sich dabei um einen eigenen Typ, der aussagt, dass er eigentlich niemals zum Einsatz kommt. Eine Funktion, die never
als Rückgabetyp angibt, zeigt an, dass sie potenziell nicht zurückkehrt, weil sie beispielsweise einen Fehler wirft.
Folgendes Listing erweitert die handleAction
-Funktion um eine Fehlerbehandlung. Sollte die Anwendung ein Objekt erhalten, das die switch
-Anweisung nicht behandelt, ruft sie die Fehlerbehandlungsfunktion auf. Interessant daran ist, dass auch das Argument der Funktion vom Typ never
ist. Eine Ergänzung des Union Type in der handleAction
-Funktion führt zu einem Fehler in der Fehlerbehandlungsfunktion, da der Typ an der Stelle nicht mehr never
ist, sondern der unbehandelte neue Typ. Damit ist zur Entwicklungszeit sichergestellt, dass der Code alle Ausprägungen des Union Type behandelt. Zur Laufzeit erfolgt ebenfalls eine Fehlerbehandlung, falls die Funktion unerwartet ein unbekanntes Objekt als Parameter erhält.
function handleAction(action: VerifyIbanAction
| VerifyAgeAction) {
switch (action.name) { // ... wie gesehen ...
}
// action ist hier never
handleInvalidAction(action);
}
function handleInvalidAction(action: never) {
// Implementierung ausgelassen
}