Typsicher und komfortabel mit TypeScript

Seite 2: Typsicheres Arbeiten mit eigenen Objekten

Inhaltsverzeichnis

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.

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()
}

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) { /* ... */ }

TypeScript kennt die Ausprägungen der name-Property (Abb. 2).

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
}