Geschrieben von

TypeScript

WebDev

JavaScript ist sehr flexibel, jedoch für die Erstellung von größeren Anwendungen oft weniger ideal. Viele Programmiersprachen informieren den Entwickler, wenn ein Codebereich geändert wird und dabei negative Auswirkungen auf andere Bereiche hat. JavaScript macht das nicht. Dies führt oft zu unerwartetem Verhalten. Um diesen Nachteil entgegen zu wirken, wurde TypeScript eingeführt. TypeScript fügt in JavaScript Typen hinzu. Durch das Typsystem hilft es potentielle Fehler zu erkennen.

Die Funktionsweise von TypeScript lässt sich relativ einfach erklären:

  1. Der TypeScript-Code wird zunächst in einer Datei mit der Endung .ts geschrieben.
  2. Der Code wird dann durch einen TypeScript-Compiler analysiert und verarbeitet, um zu prüfen, ob es den TypeScript-Standards und -Regeln entspricht.
  3. Wenn alles passt, dann übersetzt der TypeScript-Compiler den Code in eine .js-Datei.

TypeScript-Code wird dabei wie JavaScript-Code geschrieben, man fügt jedoch die Typen hinzu, was sich dann im Code widerspiegelt. In JavaScript würde ich z.B. eine String-Variable folgendermaßen setzen:

let myName = 'Demir';

In TypeScript würde das inkl. den fest gesetzten Typen für die Variable wie folgt aussehen:

let myName: string = 'Demir';

Während ich in JavaScript nun folgendes machen könnte…:

let myName = 'Demir';
myName = 1;
console.log(myName); // Gibt 1 zurück

…wäre dies mit TypeScript nicht möglich:

let myName: string = 'Demir';
myName = 1; // Type 'number' is not assignable to type 'string'.

Wie man sehen kann, würde TypeScript beim Kompilieren einen Fehler zurückgeben und so verhindern, dass überhaupt eine .js-Datei erstellt wird. In diesem Beitrag gibt es eine Einführung in TypeScript. Folgende Themen behandle ich:

Installation und Nutzung von TypeScript

TypeScript lässt sich unter anderem über npm installieren:

npm install -g typescript

Damit wird TypeScript global installiert und ist für alle deine Projekte nutzbar. Ob die Installation erfolgreich war, kannst mit folgendem Befehl prüfen:

tsc -v

Falls TypeScript installiert wurde, dann würde dieser Befehl die Version zurück geben, z.B.:

Version 4.9.4

Als nächstes kannst du einen Projekt-Ordner anlegen und dort eine einfache HTML-Datei anlegen:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <script src="app.js"></script>
    <title>TypeScript</title>
</head>
<body>
    <h1>TypeScript</h1>
</body>
</html>

Im Beispiel-Code kannst du auch sehen, dass ich im Head-Bereich auf eine app.js-Datei referenziere. Diese Datei habe ich noch nicht erstellt. Das geschieht später über TypeScript. Als nächstes legen wir daher eine Typescript-Datei im Projekt-Ordner an. Benenne sie einfach:

app.ts

In diese Datei können wir einen einfachen Code nun platzieren, wie z.B.:

let myName: string = 'Demir';

console.log(myName);

Um diesem Code nun in eine JavaScript-Datei zu kompilieren geht man wie folgt vor:

  • Über die Bash in den Projekt-Ordner navigieren
  • Befehl “tsc app.ts” ausführen

Danach müsstest du eine app.js-Datei in deinem Ordner sehen. Wenn du nun die index.html aufrufst, siehst du auch den entsprechenden Log-Befehl:

Exkurs: tsconfig.json-Datei

Mit dem Befehl “tsc app.ts” lässt sich also eine spezifische TypeScript-Datei in eine JavaScript-Datei kompilieren. Was wenn aber mehrere Dateien im Projekt-Ordner liegen? Muss dann für jede einzelne Datei das Kommando ausgeführt werden? Das ist nicht der Fall. Hier kommt die tsconfig.json-Datei ins Spiel. Über diese Datei lassen sich diverse Optionen konfigurieren. Du kannst die Datei entweder selbst erstellen und in den Projekt-Ordner auf Root-Ebene ablegen oder nutzt einen Initialisierungsbefehl im entsprechenden Projekt-Ordner:

tsc --init

Eine einfache tsconfig.json-Datei könnte wie folgt aussehen:

{
  "compilerOptions": {
    "target": "es2017",
    "module": "commonjs"
  },
  "include": ["**/*.ts"]
}

Das compilerOptions-Objekt enthält dabei Regeln, die der TypeScript-Compiler durchführen wird. Unter anderem wird mit “target” angegeben, welche Version von ECMAScript im Projekt genutzt wird. Im Beispiel ist es ES2017. “module” beschreibt, dass das Projekt die CommonJS-Syntax für das Importieren und Exportieren von Modulen nutzen wird. Innerhalb von “includes” kann bestimmt werden, welche Dateien der Compiler ausführen soll. In diesem Fall werden alle Dateien mit der .ts-Endung kompiliert. Das heißt auch, dass man im Terminal nicht mehr “tsc app.ts” angeben muss, sondern es würde “tsc” reichen und der Compiler kompiliert dann alle Dateien auf Basis der Regel in “includes”. Alternativ kann auch mit “files” eine Allow-Liste als Array angegeben werden.

Typen

Type Inference (Typinferenz)

Wie vorhin beschrieben ist JavaScript sehr flexibel und erlaubt uns einer Variable verschiedene Datentypen zuzuweisen. In der Praxis kann dieses Verhalten jedoch oft zu Fehlern führen. In TypeScript hingegen wird eine Variable mit einem Anfangswert deklariert und dieser Wert kann später nicht mehr mit einem anderen Datentypen neu zugewiesen werden. Das wird auch als “Type Inference” bezeichnet. Sprich, TypeScript erwartet von uns im kompletten Programm, dass der initial zugewiesene Datentypen der Variable bei einer Neuzuweisung immer übereinstimmen muss.

Wie oben schon gezeigt wäre folgendes mit TypeScript nicht möglich:

let myName: string = 'Demir';
myName = 1; // Type 'number' is not assignable to type 'string'.

Darüber erkennt TypeScript dann auch welche Eigenschaften und Methoden unterstützt werden. Da “myName” ein String ist, weiß TypeScript auch, dass folgende Methoden verfügbar sind:

let myName: string = 'Demir';
myName.length; // Gibt "5" zurück
myName.toLowerCase(); // Gibt "demir" zurück

Any

Es gibt jedoch auch Fälle in TypeScript, wo man den initial zugewiesenen Datentyp einer Variable mit einem anderen Datentyp setzen kann. Das ist immer dann der Fall, wenn die Variable deklariert wird, ohne ihr einen festen Typ zu geben. Wenn TypeScript in solchen Fällen den Typ nicht ableiten kann, dann wird die Variable als Typ “any” angesehen. Hier ein Beispiel:

let myToggle;
myToggle = 1;
myToggle = true;

Hier sieht man, dass die Variable zunächst ohne Typ deklariert wird. TypeScript sieht sie nun vom Typ “any” an. Dementsprechend gibt es auch keinen Fehler, wenn man zunächst der Variable einen Wert vom Typ “Number” zuweist und später einen Wert vom Typ “Boolean”. Soll jedoch bei der initialen Deklaration der Variable, jedoch ohne Wertzuweisung, der Typ gesetzt werden, dann kann das wie folgt geschehen:

let myToggle: Number;
myToggle = 1;
myToggle = true; // Gibt einen Fehler zurück

Funktionen

Durch die Flexibilität in JavaScript ist es möglich Funktionen auch mit Argument-Typen aufzurufen, die man eigentlich nicht erwartet. Das führt zwar nicht sofort zu Fehlern, hat aber Folgen wie in diesem Beispiel:

function strLength(str) {
  console.log(str.length);
}

strLength(1); // Gibt undefined zurück

Zwar gibt es hier Workarounds bzw. auch Möglichkeiten ein Error-Handling zu implementieren, ist jedoch oft umständlich wie folgendes Beispiel zeigt:

function strLength(str) {
  if (typeof str !== 'string') {
    throw new Error('Must be a string!');
  }
 
  console.log(str.length);
}

strLength(1); // Gibt "Must be a string!" zurück

Parameter-Typinferenz

In TypeScript hingegen kann man mittels der Typinferenz auch Funktionen bei der Deklaration einen Typ mitgeben, der dann sicherstellt, dass die übergebenen Parameter vom richtigen Typ sind. Das wäre in unserem Beispiel:

function strLength(str: string) {
  console.log(str.length);
}

strLength(1); // Error: argument '1' is not assignable to parameter of type 'string'

Würde man bei einem Parameter keinen Typ setzen, dann wäre dieser vom Typ “any” und kann somit jeden beliebigen Typ annehmen (wie bei Variablen; siehe oben). Ein Beispiel:

function printBoth(first: string, second) {
  console.log(`${first}: ${second}`);
}

printBoth('Demir', 'Hallo'); // Gibt "Demir: Hallo" zurück
printBoth('Demir', 1) // Gibt "Demir: 1" zurück

Optionale Parameter

TypeScript bietet auch die Möglichkeit Parameter als optional zu deklarieren. Dazu folgendes Beispiel:

function greet(name: string) {
  console.log(`Hallo, ${name || 'Unbekannt'}!`);
}
 
greet('Demir'); // Gibt "Hallo, Demir!" zurück
greet(); // TypeScript Error: Expected 1 arguments, but got 0.

In diesem Fall möchten wir eine Funktion auch ohne Parameter aufrufen, da innerhalb der Funktion ein Default-Wert mit dem || Operator gesetzt wird. Um die Funktion auch ohne Parameter aufrufen zu können, reicht in TypeScript ein “?” nach dem Parameter in der Funktionsdeklaration, was TypeScript anweist, diesen Parameter als optional anzusehen. Beispiel dazu:

function greet(name?: string) {
  console.log(`Hallo, ${name || 'Unbekannt'}!`);
}
 
greet(); // Gibt "Hallo, Unbekannt!" zurück

Default-Parameter

Eine Alternative zum optionalen Parameter ist es, dem Parameter einen Default-Wert zuzuweisen. Das soeben gezeigte Beispiel kann auch folgendermaßen umgeschrieben werden:

function greet(name = 'Unbekannt') {
  console.log(`Hallo, ${name}!`);
}
 
greet(); // Gibt "Hallo, Unbekannt!" zurück

Falls also kein Parameter beim Funktionsaufruf mitgegeben wird, dann setzt TypeScript den definierten Default-Wert. Über den Default-Wert kann TypeScript zudem den Typ ableiten.

Rückgabewert definieren

Bevor wir uns ansehen, wie man in TypeScript explizit den Typ des Rückgabewerts setzen kann, schauen wir uns an wie TypeScript auch selbst diesen Typ ableitet. Das geschieht im Grunde, indem sich TypeScript ansieht was für ein Typ nach dem return-Statement innerhalb der Funktion kommt. Hier ein Beispiel:

function greet(name: string) {
  return `Hallo, ${name}!`;
}
 
const myGreet = greet('Demir');

TypeScript weiß damit, dass myGreet vom Typ “String” sein muss. Grund ist das return-Statement in der Funktion, welches einen String zurückgibt. Andersrum würde TypeScript einen Fehler auswerfen, wenn man folgendes probiert:

function getAge(age: number) {
  return `${age} years old!`;
}
 
const myAge: number = getAge(20);

Das würde nicht gehen, da getAge() als return-Wert einen String hat. Daher lässt sich der String nicht in einer Variable vom Typ “Number” – also “const myAge: number;” – speichern.

Wenn wir jedoch den Typ des Rückgabewerts explizit setzen wollen, dann geschieht das wie folgt:

function greet(name: string): string {
  return `Hallo, ${name}!`;
}
 
const myGreet = greet('Demir');

Der Typ wird dabei direkt hinter den Parameterklammern gesetzt. In diesem Fall bedeutet das, dass diese Funktion nur Strings zurückgeben darf. Entsprechend muss man aufpassen, wenn man mit Konditionen innerhalb der Funktion arbeitet, wie bspw. nachfolgend. In Fällen, wo die Kondition nicht zutrifft, wird ein Boolean zurückgegeben, wo TypeScript einen Fehler ausgeben wird:

function greet(name?: string): string {
  if (name) {
   return `Hallo, ${name}!`;
  }

  return false;
}

Void

Falls wir Funktionen einsetzen, die keinen Rückgabe-Wert haben, dann macht es dennoch Sinn einen Typ zu setzen. Das geschieht mit dem “void”-Typ. Hier ein Beispiel:

function greet(name: string): void {
  console.log(`Hallo, ${name}!`);
}

Die Funktion würde auch ohne “void” funktionieren. Mit der Angabe des void-Typen ist jedoch auf den ersten Blick klar, dass diese Funktionen keinen Rückgabe-Wert haben.

Arrays in TypeScript

In JavaScript wird ein Array typischerweise wie folgt erstellt:

let names = ['Demir', 'Jasarevic'];

Um ein Array mit Strings in TypeScript zu definieren, müssen wir nach der Typ-Definition die eckigen Klammern setzen:

let names: string[] = ['Demir', 'Jasarevic'];

Alternativ würde auch folgende Schreibweise gehen:

let names: Array<string> = ['Demir', 'Jasarevic'];

Das sorgt dafür, dass das Array nur Daten vom Typ String enthalten kann. Entsprechend gibt folgender Push einen Fehler aus:

names.push(23);

Hier versuchen wir den Datentyp “Number” zu einem Array hinzuzufügen, welches jedoch nur Strings enthalten darf.

Multidimensionale Arrays

Um multidimensionale Arrays zu deklarieren, müssen wir zusätzliche Klammern setzen:

let arr: string[][] = [['abc', 'def'], ['ghi', 'jkl']];

Über “string[][]” wird definiert, dass wir ein Array haben “[]” welches weitere Arrays mit Strings enthält “string[]”.

Tupel erstellen

Im ersten Array-Beispiel haben wir gesehen, dass wir einen bestimmten Datentyp für das Array setzen können. Als Ergebnis können nur Daten vom definierten Typ ins Array gepusht werden. Aus JavaScript wissen wir, dass Arrays flexibler sind. Wir können in JS folgendes deklarieren:

let arr = ['Demir', 23, true];

Neben einem String, haben wir auch Number- und Boolean-Typen. Wie lässt sich das mit TypeScript erreichen? Hier das adäquate Beispiel dazu:

let arr: [string, number, boolean] = ['Demir', 23, true];

Wenn wir in TypeScript ein Array mit bestimmten Typen deklarieren, dann sprechen wir von einem Tupel (engl. Tuple). Wenn wir ein Tupel definieren, dann müssen wir uns auch über folgende 2 Aspekte im Klaren sein:

  • Das definierte Tupel gibt auch die Länge vor. Im oberen Beispiel dürfen max. 3 Elemente im Tupel sein.
  • Das definierte Tupel gibt auch die Reihenfolge vor. Im oberen Beispiel müssen die Daten genau in der Reihenfolge “String-Number-Boolean” vorkommen.

Entsprechend würden folgende Deklarationen Fehler zurückgeben:

let onlyNums: [number, number, number] = [1, 2, 3, 4]; // Type Error! onlyNums should only have three elements.
let mixIt: [string, number, boolean] = [23, 'Demir', true]; // Type Error! The first elements should be a string, the second a number, and the third a boolean.

Typinferenz bzw. Typableitung

Setzt man beim Deklarieren von Arrays keinen Typ, dann kann sich TypeScript auf Basis der enthaltenen Werte im Array den Typ selbst ableiten. Nehmen wir folgenden Code als Beispiel:

let toggles = [true, false, true];

Die Frage ist nun, ob sich TypeScript darauf basierend “boolean[]” oder “[boolean, boolean, boolean]” ableitet? Die Antwort lautet: Immer den Typ, der weniger einschränkend ist. In diesem Fall ist es “boolean[]”. Das gleiche passiert, wenn wir ein Array mit einem Tupel verketten. Das Resultat ist immer, dass die finale Variable den weniger einschränkenden Datentyp bekommt. Hier ein Beispiel:

let numT: [number, number, number] = [1, 2, 3];
let conArr = numT.concat([7, 8, 9]);

Als Resultat bekommen wir “[1, 2, 3, 7, 8, 9]”, was den Typ “number[]” (Array mit Zahlen) entspricht, da dieser Typ mit weniger Einschränkungen verbunden ist.

Rest Paramter

Über den Rest-Parameter kann man einer Funktion eine beliebige Anzahl an Argumenten übergeben, die dann in einem Array innerhalb der Funktion verfügbar sind. In TypeScript kann man auch bestimmen, von welchem Typ dieses Array sein darf. Die Deklaration sieht wie folgt aus:

function printIt (firstArg, ...args: string[]) {
   // Logik der Funktion
}

Damit ist dann der Funktionsaufruf “printIt(1, “hallo”, “demir”);” gültig, während “printIt(1, 2, 3);” einen Fehler ausgibt. Grund ist, dass der erste Parameter “firstArg” einen beliebigen Datentyp annehmen kann, da Sontiges in der Funktions-Deklaration nicht definiert wurde. Wohingegen alle anderen Parameter vom Typ String sein müssen, da sie als “…arg: string[]” an die Funktion übergeben werden.

Spread-Operator

Während der Rest-Operator den Rest bereitgestellter Werte in ein JavaScript-Array einfügt, verteilt der Spread-Operator die Elemente und macht sie “iteriebar”. Noch einfacher ausgedrückt könnte man sagen:

  • Rest verpackt Elemente.
  • Spread packt Elemente aus.

Auch in TypeScript lässt sich der Spread-Operator nutzen, um bspw. Code lesbarer zu machen. Gehen wir mal von folgender Funktion aus, die als Parameter diverse Werte annimmt:

function navi (startSouth: number,
 startNorth: number,
 startWest: number,
 startEast: number,
 endSouth: number,
 endNorth: number,
 endWest: number,
 endEast: number
) {
  // Logik
}

Die Funktion nimmt 8 Parameter entgegen, 4 davon stellen die Start-Koordinaten dar, 4 die End-Koordinaten. Um den Funktionsaufruf schöner und lesbarer zu gestalten, könnten wir zunächst die richtigen Typen und das richtige Tupel als Variable definieren:

let startNav: [number, number, number, number] = [1, 2, 3, 4];
let endNav: [number, number, number, number] = [5, 6, 7, 8];

Mit diesen Tupel-Variablen können wir sicher sein, dass die Typen mit den Parameter-Typen der Funktion “navi” übereinstimmen. Nun brauchen wir nur noch die Spread-Syntax von JS zu verwenden, um einen schönen und besser lesbaren Funktionsaufruf zu starten:

navi(...startNav, ...endNav);

Komplexe Typen

Enums

Bei Enums handelt es sich um Aufzählungen. Mit diesem komplexen Datentyp können wir alle möglichen Werte für eine Variable definieren. Während also eine String-Variable einen beliebigen Wert annehmen kann, sind es bei Enums von uns vordefinierte Werte, die die Enums-Variable annehmen kann. Um die Werte für eine Enums-Variable einzuschränken müssen wir folgende Syntax folgen:

enum Names {
  Demir,
  Max,
  John
}

Hier wird eine Aufzählung namens “Names” definiert, die nur die Werte “Demir”, “Max” und “John” annehmen kann. Hier ein paar Beispiele, wie Zuweisungen aussehen könnten:

let myNames: Names;
myNames = Names.Demir; // Keine Fehler
myNames = Names.Thomas; // Type error: Thomas is not a valid value for the Names enum.
myNames = John; // Wrong syntax, we must use Direction.John instead.

Die Verarbeitung dieses Datentyps erfolgt in TypeScript mit Aufzählungswerten. Jedem Wert in der Enums-Variable wird ihrer Reihenfolge nach ein numerischer Wert zugewiesen. Der erste Wert bekommt die Zahl 0, der zweite die Zahl 1, usw. Wenn wir dann wie im obigen Beispiel “myNames = Names.Demir” festlegen, dann wird “myNames == 0” zu “true” evaluiert. Dabei muss man berücksichtigen, dass bei unserer Enums-Variable “Names” die Zuweisung “myNames = 1” keinen Error ausgibt, da “Names.Demir”, “Names.Max” und “Names.John” die Werte 0, 1 (hier dann die Übereinstimmung zu “myNames = 1”) und 2 besitzen.

Wir können aber auch die Start-Nummer ändern:

enum Names {
  Demir = 2,
  Max,
  John
}

Wir hätten dann die Reihenfolge 2, 3 und 4. Zudem lassen sich auch zufällige Werte, also nicht der Reihe nach, definieren:

enum Names {
  Demir = 1,
  Max = 7,
  John = 5
}

Unabhängig davon nach welchem Prinzip die Aufzählung definiert wird, es handelt sich dabei immer um numerische Aufzählungen, da sie auf Zahlen basieren. In TypeScript können wir dies auch basierend auf Strings definieren – sogenannte “String-Enums”. String-Enums lassen sich ähnlich konfigurieren wie Number-Enums. Hier zunächst nochmal die Definition eines Number-Enums:

enum Names {
  Demir,
  Max,
  John
}

Und hier das gleiche Enum, nur als String-Enum:

enum Names {
  Demir = 'DEMIR',
  Max = 'MAX',
  John = 'JOHN'
}

Wie man sehen kann, werden bei numerischen Enums die Zahlen automatisch zugewiesen, diese müssen nicht explizit gesetzt werden. “Demir” wäre 0, “Max” wäre 1 und “John” wäre 2. Bei String-Enums müssen die String-Aufzählungen explizit von uns gesetzt werden. Gängig ist es hierzu den Wert der Variable einfach in Großschreibung als Aufzählungswert zu setzen, also “Demir = ‘DEMIR'”. Theoretisch könnte aber eine beliebige Zeichenfolge gesetzt werden (was aus Debugging-Zwecken nicht empfohlen wird).

Der Vorteil von String-Enums gegenüber Number-Enums ist, dass bei String-Enums keine Zahlen mehr zugewiesen werden können. Im Falle eine Number-Enum wären folgende Zuweisungen erlaubt, weil sie identisch sind:

let myNames: Names;
myNames = 1; // Zulässig, da 1 den Aufzählungswert von "Max" entspricht.
myNames = Names.Max; // Zulässig - identisch mit obiger Zeile

Aber nicht nur das: Auch zufällige Zahlen, die keinem Aufzählungswert der Number-Enum entsprechen, sind möglich:

myNames = 252525;

Um dieses Verhalten zu vermeiden, sind String-Enums oft die bessere Wahl, da sie strenger sind. Hier als Beispiel mit einer String-Enum:

let myNames: Names;
myNames = 'IRGENDWAS'; // Type Error
myNames = 'DEMIR'; // Auch ein Type Error
myNames = Names.Demir // Nur das hier ist zulässig

Objekte

Die Definition von Objekten in TypeScript ähnelt der Literalschreibweise. Statt Werte haben wir die Typen:

let Person: {
   name: string,
   age: number
};

Eine korrekte Zuweisung würde wie folgt aussehen:

Person = {name: 'Demir', age: 34};

Fehlerhaft wären bspw. folgende Versuche:

Person = {name: 'Abc', age: 'Def'}; // Type error: age property has the wrong type.
Person = {name: 'Max', years: 33}; // Type error: no years property.

Die Typ-Definition von Objekten können in TypeScript auch weiter verschachtelt werden wie in diesem Beispiel:

let Car: {
  name: string,
  producer: {name: string, year: number},
  drivers: {name: string, age: number}[],
  cost: number
};

Typ Aliase

TypeScript gibt uns die Möglichkeit Typ Aliase zu setzen. Das heißt wir können alternative Typ-Namen vergeben. Die Syntax dabei lautet:

type <alias name> = <type>;

Um den Typ “String” einen anderen Namen zu vergeben reicht dazu:

type CustomString = string;

Im Anschluss kann statt “string” der Alias benutzt werden:

let name: CustomString = 'Hello';

In diesem Beispiel ist ein Typ Alias nicht wirklich hilfreich, aber es gibt Fälle wo sie nützlich sind. Das trifft vor allem dann zu, wenn man auf komplexe Typen wiederholt referenziert – vor allem auf Objekt- und Tupeltypen. Gehen wir vom folgenden Code-Beispiel aus:

let Group: {
  groupName: string,
  leader: {name: string, age: number},
  members: {name: string, age: number}[],
  oldestMember: {name: string, age: number},
  youngestMember: {name: string, age: number},
  groupId: number,
  futureJoiners: {name: string, age: number}[]
};

Hier können wir sehen, dass wir oft den selben Typen immer wieder einsetzen. Dabei handelt es sich um “{name: string, age: number}”. Dafür können wir nun einen Typ Alias setzen und im Anschluss ein deutlich besser lesbares Objekt schreiben:

type Person = {name: string, age: number};

let Group: {
  groupName: string,
  leader: Person,
  members: Person[],
  oldestMember: Person,
  youngestMember: Person,
  groupId: number,
  futureJoiners: Person[]
};

Wenn man mit Typ Aliase arbeitet muss man beachten, dass man damit keinen neuen Typ “erschafft”, sondern lediglich einen anderen Namen für einen existierenden Typen setzt. Nehmen wir folgende zwei Typ Aliase als Beispiel:

type CustomString: string;
type OtherString: string;

Nun versuchen wir folgendes:

let firstString: CustomString = 'Hello';
let secondString: OtherString = firstString;

Hier sieht man, dass man den Wert von “firstString” für “secondString” zuweisen kann, da beide im Kern den selben Typen (String) haben, obwohl sie nun einen anderen Alias haben.

Funktionstypen

In JavaScript können Funktionen Variablen zugewiesen werden. In TypeScript auch, jedoch können wir in TS die Arten von Funktionen, die einer Variable zuweisbar sind, steuern. Das lässt sich über sogenannten Funktionstypen erreichen. Diese geben neben den Typen für die Argumente auch den Rückgabetyp an. Um bspw. einen Funktionstyp zu definieren, der nur zwei String-Argumente annimmt und eine Zahl zurückgibt, müssen wir wie folgt vorgehen:

type MyStringToNumberFunc = (firstArg: string, secondArg: string) => number;

Dadurch setzen wir unseren Typen für die Funktion fest und können nun diesen Typen jeder kompatiblen Funktion zuweisen:

let getNameLength: MyStringToNumberFunc;

getNameLength = function(firstName: string, lastName: string) {
  return firstName.length + lastname.length;
};

Da die getNameLength-Funktion genau den Anforderungen unserem Funktionstypen entspricht, ist die Zuweisung erfolgreich. Zudem können wir sehen, dass die Parameter-Namen der Funktion keine Rolle spielen. Daher ist es auch “egal” wie wir die Parameter im Funktionstypen (in unserem Beispiel “firstArg” und “secondArg”) nennen.

Generische Typen

TypeScript gibt uns die Möglichkeit sogenannte “generische Typen” zu definieren, die eine Sammlung eigener Typen sind. Gehen wir vom folgenden Beispiel aus:

type Car = {
 tires: [C, C],
 driver: C,
 passengers: C[]
};

Wie man sehen kann setzen wir nach dem Typ-Namen in spitzen Klammern einen Platzhalter. Statt “C” könnten wir auch was anders beliebiges mitgeben wie “A” oder “MyType”. Wenn wir nun diesen Typen nutzen möchten, müssen wir den Platzhalter mit einem validen Typen ersetzen. TS setzt dann diesen Typen in alle Platzhalter. Hier ein Beispiel:

let oneCar: Car = {
  tires: ['summer', 'winter'],
  driver: 'Demir'
  passengers: ['Max', 'Noch Einer', 'Und noch einer', 'Usw']
};

Dieser Ansatz kann auch für Funktionen genutzt werden, um generische Funktionen zu definieren. Gehen wir davon aus wir haben eine Funktion, die uns ein Array mit n übergebenen Werten erstellt. Sprich:

function fillArray(value, n) {
   return Array(n).fill(value);
};

Wenn ich nun “fillArray(‘Demir’, 4);” aufrufe, dann bekomme ich:

['Demir', 'Demir', 'Demir', 'Demir']

Ich muss dabei nicht unbedingt einen String als Wert übergeben. Das würde genau so mit einer Zahl funktioniert, wie z.B. mit “fillArray(1, 4);”:

[1, 1, 1, 1]

Mit einer generischen Funktion können wir nun den Rückgabetypen so setzen, dass dieser ein Array des übergebenen Wert-Typen sein sollte. Dadurch sparen wir uns, dass wir für jeden Wert-Typ (also z.B. einmal für den String, einmal für Number, usw.) eine separate Typanmerkung schreiben müssen. Die generische Funktion würde wie folgt aussehen:

function fillArray(value: C, n: number): C[] {
   return Array(n).fill(value);
};

In diesem Fall stellen wir sicher, dass der übergebene Wert (value) den selben Typen hat wie das zurückgegebene Array (C[]). Wir können nun folgenden Funktionsaufruf durchführen:

fillArray('Demir', 4);

Als Ergebnis würden wir wieder folgendes bekommen:

['Demir', 'Demir', 'Demir', 'Demir']

Union Typen

Bisher konnten wir sehen, dass wir mit TypeScript zum einen explizit sagen können welchen Typ eine Variable haben kann. Setzen wir für die entsprechende Variable den Typen “String” fest, so darf diese Variable nur Strings annehmen, keine Typen “Number” oder “Boolean”. Auf der anderen Seite gibt es die andere “Extreme” – den Typ “any”. Damit lässt TypeScript jeden beliebigen Typ zu. Es gibt jedoch Fälle, wo wir etwas in der Mitte brauchen. Ein klassisches Beispiel sind IDs, die als “Number” oder “String” agieren können. Wir könnten hier zwar “any” nutzen, wie hier:

let customerId: any;

Das Problem ist jedoch, dass wir damit jeden Typen zulassen und nicht nur String und Number. Hier kommen dann Vereinigungstypen (Union) ins Spiel. Neben den Typ-spezifischen Deklaration und der any-Deklaration stellen Union-Typen einen Kompromiss bereit.

Union Typen definieren

Mit Union können wir also mehrere Typen definieren. Bleiben wir bei unserem Beispiel mit der ID. Einen Union können wir definieren, indem wir bei der Deklaration jeden Typen mit einen senkrechten Strich trennen:

let customerId: string | number;

// Zuweisung mit Number
customerId = 1;

// Zuweisung mit String
customerId = '1';

Mit “string | number” sagen wir, dass die Variable den Typ “String” oder “Number” annehmen kann. Dies ist etwas lockerer als die explizite Typ-spezifische Deklaration und gleichzeitig spezifischer als “any”. Ein Union kann überall benutzt werden. Zum Beispiel in Funktionsparametern:

function printId(id: string | number) {
   return id;
};

Type Narrowing

Die soeben gezeigte Funktion hat jedoch ein Problem: Was wenn wir innerhalb der Funktion “toLowerCase();” aufrufen? Sollte ein String übergeben werden, dann ist das kein Problem. Wenn der übergebene Wert jedoch eine Zahl ist, dann bekommen wir einen TypeError zurück, da toLowerCase() auf dem Typ “Number” nicht angewendet werden kann. Um das zu vermeiden müssen wir zunächst einen sogenannten “Type Guard” implementieren:

function printId(id: string | number) {

   if (typeof id === 'string') {
     // Hier läuft nur Code, wenn der id-Parameter ein String ist
   }

};

Innerhalb der if-Kondition können dann String-spezifische Methoden und Logiken implementiert werden. Alles außerhalb der if-Kondition funktioniert dann für Strings und Zahlen. Dieses Konzept wird in TypeScript als “Type Narrowing” bezeichnet.

Unions in Arrays

Unions können auch für Arrays definiert werden. Wenn wir bspw. ein Array definieren möchten, welches Strings und Zahlen zulässt, dann können wir das wie folgt machen:

const myArr: (string | number)[] = [1, 'Test'];

Dadurch haben wir ein Array namens “myArr”, welches Strings und Zahlen zulässt. Ein “myArr.push(true)” würde nicht klappen. Wichtig zu erwähnen ist, dass die runden Klammern essentiell bei der Definition sind. Ohne den Klammern würde es TypeScript als “string | number[]” interpretieren. Das würde bedeuten, dass die Variable ein String oder ein Array mit Zahlen sein kann.

Geteilte Methoden und Eigenschaften

Bei der Nutzung von Unions wie folgt muss man berücksichtigen, dass TypeScript anschließend nur gemeinsame Methoden und Eigenschaften beider Typen erlaubt.

const status: boolean | number = false;
 
status.toString(); // Kein Fehler
status.toFixed(2); // TypeScript Error

Da die Konstante “status” ein Boolean oder eine Zahl sein kann, erlaubt TypeScript nur Methoden, die von beiden geteilt werden. Das wäre bspw. toString(), jedoch nicht toFixed(), da letztere nur bei Zahlen unterstützt wird. Selbes Prinzip haben wir bei Objekten. Gehen wir von folgenden zwei Objekten aus:

type Obj1 = {
  isClear = boolean;
  hasColor = boolean;
  hasParent = boolean;
}

type Obj2 = {
  isClear: boolean;
  hasOther: boolean;
}

Nun nutzen wir beide Objekte und führen sie zu einem Union zusammen:

const myObj: Obj1 | Obj2 = {isClear: true};

Der folgende Versuch würde klappen, da beide Objekte die Eigenschaft besitzen:

console.log(myObj.isClear);

Der folgende Aufrufe würde einen Fehler zurückgeben, da die hasOther-Eigenschaft nur bei einem der Objekte enthalten ist:

console.log(myObj.hasOther); // Gibt einen Fehler zurück

String Literal-Typen

TypeScript-Unions können zudem auch mit Literaltypen genutzt werden. Damit können wir unter anderem sicherstellen, dass einer Funktion nur bestimmte Parameter-Werte übergeben werden können. Hier ein Beispiel:

type Color = 'blue' | 'green' | 'red';

function paintCar(color: Color) {
  // Hier kommt Logik
};

Hier wird sichergestellt, dass nur die Farben “blue”, “green” und “red” an die Funktion paintCar() übergeben werden dürfen. Ein Aufruf mit “paintCar(‘yellow’)” würde zu einen TypeScript-Fehler führen.

TypeScript und der in-Operator

Weiter oben hatte ich über “Type Narrowing” und “Type Guard” ein Code-Beispiel geteilt:

function printId(id: string | number) {

   if (typeof id === 'string') {
     // Hier läuft nur Code, wenn der id-Parameter ein String ist
   }

};

Wie man sehen kann, nimmt die Funktion entweder einen String oder eine Zahl entgegen. Damit sichergestellt wird, dass String-Methoden nur angewendet werden, wenn ein String übergeben wird, kommt die typeof-Abfrage zum Einsatz. Was wenn wir jedoch nicht prüfen möchten, ob der Input einem bestimmten Typ entspricht, sondern eher prüfen möchten, ob eine Methode für den Input generell existiert? Hier kommt der in-Operator zum Einsatz. Der in-Operator prüft, ob eine Eigenschaft auf einem Objekt oder in seiner Prototypkette vorhanden ist. Falls ja, gibt die Abfrage ein “true” zurück. Hier ein Beispiel:

type Person = {
  walk: () => void;
}

type Car = {
  drive: () => void;
}

function goAhead(move: Person | Car) {
  if ('walk' in move) {
    return move.walk();
  }

  if ('drive' in move) {
    return move.drive();
  }
}

In diesem Beispiel wird mit dem in-Operator zunächst geprüft, ob die Eigenschaft “walk” für “move” vorhanden ist. Falls ja, dann wird TypeScript den Typ innerhalb der if-Bedingung auf “Person” einschränken (Typ Guarding). Selbiges gilt dann für die nächste if-Prüfung.

Objektorientiert mit TypeScript

Interfaces

Weiter oben haben wir gesehen, wie man mit TypeScript eigene Typen definieren kann. Hier nochmal ein Beispiel:

type Person = {
  name: string;
  age: number;
}

const newPerson: Person = ...

Es gibt aber auch mit Möglichkeit das interface-Keyword dafür zu nutzen:

interface Person {
  name: string;
  age: number;
}

const newPerson: Person = ...

Die Syntax beider Möglichkeiten ist sehr ähnlich. Beim “interface” wird das Gleichheitszeichen nicht benötigt, ansonsten sind beide identisch. Was ist dann der Unterschied?

  • interface kann nur zum Definieren von Objekten genutzt werden.
  • type kann für Objekte, primitive Typen, etc. genutzt werden.

Wenn wir also einen Typen für objektorientierte Programme brauchen, dann empfiehlt es sich “interface” statt “type” zu nutzen. Das ist besonders hilfreich bei Klassen. Gehen wir vom folgenden “interface” aus:

interface Person {
   identify: (id: number) => void;
}

Mit dem implements-Keyword können wir nun diesen Typen auf eine Klasse anwenden:

class onePerson implements Person {
   identify(id: number) {
      console.log(`Hallo, ich bin Nummer ${id.toFixed(2)}.`);
   }

   saySomething() {
      console.log('Yo!');
   }
}

Bei diesem Beispiel haben wir also einen Typ namens “Person” und eine Klasse namens “onePerson”. Über das implements-Keyoword wird nun der Typ “Person” auf die Klasse “onePerson” angewendet. Da “Person” die Methode “identify” besitzt, muss die Klasse “onePerson” ebenfalls diese Methode besitzen, damit es dem Typ “Person” entspricht (andernfalls gibt TypeScript einen Fehler aus). Aber: Die Klasse “onePerson” kann auch eigene Methoden – wie “saySomething” in unserem Beispiel – besitzen.