Suche
TypeScript Tutorial - Teil 1

TypeScript Tutorial: Grundlagen und Typisierung für JavaScript

Johannes Dienst

© Shutterstock / Billion Photos

TypeScript ist eine echte Obermenge von JavaScript mit optionaler statischer Typisierung. Es bietet schon heute zahlreiche Features aus den zukünftigen ECMAScript-Standards. Dabei transpiliert es zu JavaScript in der ECMAScript-Version 3/5 und ist damit kompatibel zu allen Browsern. Vor Kurzem wurde Version 2.0 veröffentlicht. Der richtige Zeitpunkt, sich TypeScript genauer anzusehen.

In diesem TypeScript Tutorial geben wir eine ausführliche Einführung in die populärere JavaScript-Variante. In fünf Teilen lernen Sie die sprachlichen Grundlagen und die Möglichkeiten der Typisierung kennen und wenden das Erlernte gleich in der Praxis an. Starten wollen wir in Teil 1 dieses Tutorials mit den Grundzügen von TypeScript als Obermenge von JavaScript und den neu hinzugekommenen Features zur Typisierung.

TypeScript Tutorial: Teil 1 – Grundlagen und Typisierung

Mitte September 2016 haben die Entwickler Version 2.0 von TypeScript veröffentlicht. Die ausgereifte Obermenge von JavaScript erreicht damit einen weiteren Meilenstein ihrer Entwicklung. Das wichtigste Feature ist die optionale statische Typisierung, die laut Projektleiter Anders Hejlsberg JavaScript-Entwicklung in großem Stil ermöglicht. Die 2012 als Open Source veröffentlichte Sprache wird unter großem Einfluss der Community weiterentwickelt. Sie transpiliert wahlweise zu ECMAScript 3, 5 oder 6 und garantiert dadurch auch, dass das erzeugte JavaScript auf allen Browsern lauffähig ist. Abgerundet wird das Paket durch ein Tooling, das sich in das Ökosystem rund um Node.js und npm einfügt.

TypeScript Tutorial: Inhalt

Teil 1: TypeScript-Grundlagen/Typisierung für JavaScript

Teil 2: Integration in IDEs und Editoren

Teil 3: Workflow/Aufsetzen eines Projekts

Teil 4: TypeScript in der Praxis

Teil 5: Ausblick in die Zukunft von TypeScript

Etablierte IDEs und Editoren profitieren von der Architektur des TypeScript-Compilers, durch die Plug-ins mit Codevervollständigung und Refaktorisierungsmöglichkeiten gebaut werden können. Mit den vorhandenen Werkzeugen sind große JavaScript-Codebasen handhabbar. Spätestens seit Google für sein beliebtes Framework Angular auf TypeScript setzt, kann man davon ausgehen, dass das Projekt ein langes Leben haben wird.

Hauptmerkmal von TypeScript ist die optionale statische Typisierung. Dafür stellt es eine ganze Reihe von Basistypen bereit, die in Listing 1 in Aktion gezeigt werden. Neben den klassischen Typen für boolesche Werte boolean, string für Strings und number für Zahlenwerte im Format dezimal, hexadezimal, binär und oktär, lassen sich auch Arrays definieren, sogar auf zwei Arten: klassisch zum Beispiel ein Array aus strings mit

  let aArray: string[] = ['Hello', 'World', '!'];

oder mit Generics

let aArray: Array<string> = ['Hello', 'World', '!'];

Ein letzter Basistyp ist enum, mit dem sich Aufzählungen definieren lassen, die aufsteigend von 0 angelegt werden. Alternativ können auch die numerischen Werte selbst belegt werden.

let isDone: boolean = false;
let decimal: number = 6;
let color: string = 'berlin';

// Getype Arrays auf zwei Arten
let aArray: number[] = [1, 2, 3];
let aGenericArray: Array<number> = [4,5,6];

enum Color {Red, Green, Blue};
let c: Color = Color.Green;

Die Migration von JavaScript auf TypeScript bringt mitunter Fehler ans Tageslicht. Diese betreffen die Typisierung der Codebasis und Spezialfälle, mit nicht klar aufgelösten Typen. Dafür gibt es auch einige Spezialtypen, die die Integration mit JavaScript vereinfachen. Listing 2 zeigt zuerst den Typ any, der immer dann zum Einsatz kommen kann, wenn eine Variable Werte von unterschiedlichen Typen annehmen kann. Arrays können auch verschiedene Typen enthalten, so wie:

let tuple: [string, number];

Zu beachten ist hierbei, dass damit eine Reihenfolge verbunden ist und ab Index 2 jeder der beiden Typen erlaubt ist. Folgende Zuweisungen sind also korrekt:

tuple = ["Hello", 42];

tuple = ["Hi", 42, 27, "Zoe"];

// Kann jeden Typ enthalten
let notSure: any = 4;
notSure = 'I am undecided';

let tuple: [string, number];
tuple = ["Hello", 42];

Seit Version 2.0 gibt es einen neuen Typ never, der immer dann zum Einsatz kommt, wenn eine Methode nicht korrekt terminiert. Das wird gerne mit void verwechselt, wenn eine Methode nichts zurückgibt. Die Methode in Listing 3 wirft immer einen Fehler, terminiert also nicht korrekt. Deswegen bekommt sie als Rückgabewert den sogenannten Bottom Type never, der genau dieses Verhalten ausdrückt. Das lässt sich dazu benutzen, Checks zur Lauf- und Kompilierzeit einzubauen, die auf dieses Verhalten prüfen. Eine genaue Erläuterung findet sich hier sowie hier.

Listing 3
// Falls Methoden keinen Rückgabewert haben: never
function error(message: string): never {
  throw new Error(message);
}

TypeScript: Warum Sie zuschlagen sollten

Größtes Verkaufsargument von TypeScript ist die optionale statische Typisierung. Tatsächlich ist sie ziemlich ausgereift und der Compiler zuverlässig und schnell. Typen lassen sich mit Interfaces relativ leicht erzeugen. Listing 4 definiert ein Interface Robot. Es kann sowohl Properties als auch Funktionssignaturen enthalten. Eine Besonderheit stellen sogenannte optionale Properties dar. Sie werden mit einem ? markiert, wie die Property model. In einer konkreten Ausprägung können sie dann vorhanden sein oder auch nicht.

interface Robot {
  name: string;
  model?: number;
  isDiscontinued?: boolean;
  sayName(): void;
}

Eine Klasse kann dieses Interface implementieren und der Compiler überprüft die Einhaltung des Vertrags. Hier zeigt sich schon der große Vorteil von statischer Typisierung. Bei einer großen Codebasis ist es nicht immer leicht, diese Einhaltung nur durch eine hohe Testabdeckung zu garantieren. In TypeScript gibt es mit dem Compiler ein Sicherheitsnetz für diesen Fall. Die Klasse C3PO ist eine gültige Implementierung, obwohl die optionalen Properties model und isDiscontinued fehlen. Sie werden vom Compiler nicht gefordert.

class C3PO implements Robot {
  name: string;

  constructor(name: string) {
    this.name = name;
  }

  sayName(): void {
    alert(this.name);
  }
}

Interessanter aber ist es zu sehen, dass zwar Properties fehlen können, jedoch keine übermäßigen vorhanden sein dürfen, um das Interface zu implementieren. Folgender Code wäre also nicht gültig:

let wrongC3PO: Robot = {name : 'Falsum', sayName : function(){}, excess : true};

Um wahre Objektorientierung bereitzustellen, fehlt noch ein wichtiges Merkmal: Datenkapselung. Dazu gibt es in TypeScript die teilweise aus anderen Sprachen bekannten Modifier public, protected, private und readonly (Listing 6). Die ersten drei verhalten sich dabei so, wie man es aus Sprachen wie Java gewohnt ist. Lediglich die package-Sichtbarkeit bei protected verhält sich aufgrund der fehlenden package-Struktur in JavaScript anders. Nur von einer Klasse erbende Unterklassen können auf eine mit protected gekennzeichnete Property zugreifen. Der Modifier readonly ist das Pendant zu final in anderen Sprachen. Mit readonly versehene Properties lassen sich genau einmal bei der Erzeugung eines Objekts setzen.

class TinyThing {
  name: string;
  protected weight: number;
  private age: number;
  readonly color?: string;
}

Eigenschaften der Typisierung mit TypeScript

Das Typsystem von TypeScript ist grundsätzlich nicht „sound“ aufgebaut. Das heißt, dass es in Spezialfällen keinen Fehler generiert, wenn zur Kompilierungszeit keine korrekte Typisierung ermittelt werden kann. Das hat unter anderem auch mit der Natur von JavaScript zu tun, das sich oft solche Spezialfälle zu Nutze macht, für eine Erklärung dieser Fälle siehe hier. Ansonsten hat das Typsystem vier Eigenschaften (Listing 8) und ist zudem optional. Der Compiler meckert also nicht, wenn keine Typisierung angewandt wird. In Kombination mit struktureller Typisierung ergibt sich dadurch sogar der nette Nebeneffekt, dass der Compiler den Typ in fast allen Fällen selbst richtig ableiten kann, eine Eigenschaft, die auch als Duck Typing bezeichnet wird. Interessant und mächtig ist auch die lokale Typinferenz. Wird einer ungetypten Variablen ein neu erzeugtes Objekt zugewiesen, z. B. mit

let c3po = new C3PO('c1po');

dann weiß der Compiler, dass in c3po ein Objekt des Typs C3PO steckt. Als letzte Eigenschaft ist die kontextuelle Typisierung zu nennen. Hier kann der Compiler aufgrund der Umstände den Typ ermitteln. So weiß er, dass bei window.onmousedown ein MouseEvent an die Funktion übergeben wird, das bestimmte Eigenschaften (nicht) hat (Listing 7).

// optional
let myRobot = {name: 'Arnold', sayName: function(){}};

// structural
printRobotInfo(myRobot);

// local
let c3po = new C3PO('c1po');
c3po = 42; // error

// contextual
window.onmousedown = function(mouseEvent) {
  console.log(mouseEvent.buton); // <- Error
};

Wann wir Funktionstypen brauchen

JavaScript ist von jeher eine funktionale Sprache. Es ist nur konsequent, Typen für Funktionen einzuführen. Diese können in TypeScript sogar über ein Interface definiert werden (Listing 8). Dabei wird in Klammern die Parameterliste angegeben und anschließend der Rückgabetyp. Mit void zeigt man an, dass eine Funktion keinen Rückgabewert besitzt. Wird nicht der Weg über das Interface gegangen, so kommt die Schreibweise mit Fat Arrow zum Einsatz: (p1, p2, …) => T. . Besonders nützlich sind Funktionstypen, wenn in einer Codebasis Callbacks verwendet werden, oder generell funktional programmiert wird. Beide Fälle sind vor allem in JavaScript-Codebasen mit asynchronen Aufrufen häufig anzutreffen.

interface LameFunc {
  (): void;
}

let funcType: LameFunc;
let funcType2: (robot)=>void;

funcType = printRobotInfo; // Error: Wrong function type
funcType2 = printRobotInfo;

Ein paar Worte zur Typdefinition

In TypeScript kann ein Interface sowohl die statische als auch die Instanzseite gleichzeitig beschreiben. Das klingt zuerst verwirrend. Tatsächlich trägt das Handbuch auch nicht zur Klärung bei. Sehen wir uns dazu das Beispiel in Listing 9 an. Das Interface definiert auf der Instanzseite eine Property age. Auf der statischen Seite wird der Konstruktor mit dem Schlüsselwort new definiert. Die Klasse Mortal implementiert das Interface, aber es gibt einen Fehler, da der Compiler die Methode new nicht finden kann. Warum ist das so, obwohl ein Konstruktor vorhanden ist, der diese Signatur aufweist?

interface MortalInterface {
  age: number;
  new (age: number);
}

// Type Mortal provides no match for the signature 'new (age :number) :any'
class Mortal implements MortalInterface {
  age: number;
  constructor(age: number) { }
}

Erstellt man ein Objekt mit let aMortal = new Mortal(42), wird der Konstruktor aufgerufen. Anschließend ist er nicht mehr zugreifbar, da er nicht als Methode des Objekts vorhanden ist. Ein Aufruf wie aMortal.new(42) ist also nicht möglich. Das Interface MortalInterface fordert aber, dass eine solche Methode vorhanden ist. An dieser Stelle hilft eine Verbildlichung wie in Abbildung 1. Ein Interface sollte immer nur die Instanzseite oder die statische Seite beschreiben. Beides zusammen ist zwar möglich, aber der Compiler überprüft immer nur die Instanzseite – man kann dann keine Klasse implementieren, die diese Vorgaben erfüllt.

TypeScript Tutorial: Grundlagen und Typisierung für JavaScript

Abb. 1: Zusammenhang zwischen statischer und Instanzseite von Interfaces und Klassen

Die Definition der statischen Seite ergibt nur Sinn, wenn eine Factory gebaut werden soll. In Listing 10 werden zwei Interfaces zur Konstruktion eines Objekts der Klasse Human definiert. HumanConstructor beschreibt die Signatur des Konstruktors, HumanInterface die Properties, die das Objekt beinhalten soll. Will man jetzt eine konkrete Klasse Human erstellen, dann implementiert sie das Instanzinterface HumanInterface und sie muss einen Konstruktor wie in HumanConstructor bekommen.

Die Fabrikmethode createHuman erwartet ein Objekt, das die Konstruktorvorgabe von HumanConstructor erfüllt, und gibt ein Objekt des Typs HumanInterface zurück. Voilà, damit wurde die statische Seite mit der Instanzseite kombiniert.

interface HumanConstructor {
  new (age: number): HumanInterface;
}
interface HumanInterface {
  age: number;
}

class Human implements HumanInterface {
  age: number;
  constructor(age: number) {
    this.age = age;
  }
}

function createHuman(ctor: HumanConstructor, age: number): HumanInterface {
  return new ctor(age);
}

let dijkstra = createHuman(Human, 86);

Klassen und Methoden parametrisieren mit Generics

Generics werden allgemein benutzt, um Klassen und Methoden mit Typen parametrisieren zu können. Dadurch können z. B. die Typen der Elemente, die in einem Array enthalten sind, dem Compiler mitgeteilt werden:

Array<string>

Für Entwickler ist das insofern hilfreich, dass dadurch schon zur Kompilierzeit eine Typenprüfung stattfindet. Noch dazu ist der Typ der enthaltenen Elemente bekannt und kann zum Beispiel für IntelliSense benutzt werden. Im folgenden Beispiel wird ein weiterer Anwendungsfall gezeigt, in dem eine Methode parametrisiert wird. Die Idee stammt von folgenden Blog, sie wurde hier angepasst und korrigiert. Listing 11 implementiert eine generische Funktion mit der sogenannten Type-Variable T (Schreibweise <T>), ), die einen asynchronen Aufruf an ein API tätigt. Als Rückgabetyp wird ein Promise definiert, das am Ende JSON ausgeben soll. Da Objekte in JavaScript praktisch im JSON-Format sind – daher auch der Name JavaScript Object Notation –, kann der Rückgabewert wieder auf den übergebenen Typ gecastet werden. Das geht mit dem Diamantoperator:

<Promise<T>>response.json()

function getAsync<T>(arg: T): Promise<T> {
  // ERROR: Property 'id' does not exist on type 'T'
  return fetch(`/api/${arg.id}`)
    .then((response: Response) => <Promise<T>>response.json());
}

Leider gibt es hier ein Problem, denn der Typ des übergebenen Parameters arg ist nicht eingeschränkt. Es kann also vom Compiler nicht ermittelt werden, ob arg tatsächlich eine Property-ID besitzt. Der Typ wird nach any aufgelöst. Hier kommen Generic Constraints zum Einsatz. Dafür legen wir ein neues Interface Identity an, das eine Property id enthält:

interface Identity { id: string; }

Die Signatur von getAsync() wird erweitert und sieht anschließend so aus:

function getAsync<T extends Identity>(arg: T): Promise<T>

Wie hilft das insgesamt dem Entwickler? Da die Methode getAsync() über die Type-Variable parametrisiert werden kann, ist eine beliebige Anfrage an das API möglich, das ein Objekt vom übergebenen Typ zurückliefert. Das komplette Beispiel ist in Listing 12 zu finden. Die Klasse Movie implementiert das zuvor besprochene Interface Identity und kann damit als Type-Variable von getAsync() verwendet werden. Der Aufruf von getAsync() mit Movie als Type-Variable und einem Parameter MovieObjekt hat zur Folge, dass im then-Block später die Typinformation verfügbar ist. Der Entwickler hat vollen Zugriff auf IntelliSense, da movie nicht vom Typ any ist, sondern vom Typ Movie.

interface Identity { id: string; }

class Movie implements Identity {
  id: string;
}

function getAsync<T extends Identity>(arg: T): Promise<T> {
  return fetch(`/api/${arg.id}`)
    .then((response: Response) => <Promise<T>>response.json());
}

let movieToFind: Movie = { 'id' : '42' };
getAsync<Movie>(movieToFind)
  .then(movie => {
    console.log(movie.id);
  });

ECMAScript 20XX

TypeScript wurde neben der optionalen statischen Typisierung auch entwickelt, um zukünftige JavaScript-Sprachfeatures früh zugänglich zu machen, indem sie zu ECMAScript 3/5 transpiliert werden. Der Artikel stellt nur eine Auswahl der am meisten erwarteten ECMAScript-2016/17-Sprachmittel vor, die schon heute in TypeScript verfügbar sind. Wer sich ein genaueres Bild machen will, kann sich eine detaillierte Übersicht hier anzeigen lassen. Den Start machen Dekoratoren, die wahrscheinlich in ECMAScript 2018 volle Unterstützung bekommen. Dekoratoren sind einfache Funktionen die als Parameter target, name und descriptor erhalten. Listing 13 zeigt einen einfachen Decorator log, mit dem Methoden in einer Klasse dekoriert werden können. Funktionen außerhalb von Klassen sind nicht dekorierbar. Erzeugt man nun ein Objekt vom Typ Greeter mit

var greeter = new Greeter();

dann bekommt man in der Konsole folgende Ausgabe:

Greeter {}

greet

Object {

value: [Function: greet],

writable: true,

enumerable: false,

configurable: true }

Das eröffnet viele Möglichkeiten, auf die Ausführung der Methode Einfluss zu nehmen. Für Querschnittsaspekte ist diese Vorgehensweise empfehlenswert. Neben Methoden können auch Klassen dekoriert werden. Dort können dann zum Beispiel einzelne Properties readonly gesetzt werden, indem im Descriptor writable auf false gestellt wird. Die Möglichkeiten sind enorm. Für eine detailliertere Einführung siehe folgenden Link.

function log(target: Object, name: string, descriptor: PropertyDescriptor) {
  console.log(target);
  console.log(name);
  console.log(descriptor); 
}

class Greeter {

  @log
  greet(x, y) {
    return x + y;
  }
}

Ein weiteres heiß erwartetes Feature ist Async/Await, das die Arbeit mit asynchronen Funktionen vereinfacht, insbesondere mit Promises. Der Sinn dahinter ist, dass das Programmiermodell wie bei synchronem Code aufgebaut wird, aber im Hintergrund tatsächlich asynchron abläuft. Ab TypeScript 1.7 ist es verfügbar, wenn zu ECMAScript 2015 transpiliert wird, seit TypeScript 2.1 ist es ebenfalls für ECMAScript 5 verfügbar. Sehen wir uns dazu das Beispiel aus Listing 14 an. Die Funktion afunc wird mit dem Schlüsselwort async versehen. Das ist nötig, damit innerhalb der Funktion ein await benutzt werden kann. Ruft man afunc nun auf mit let res = afunc(); wird auf der Konsole Folgendes ausgegeben:

Hello Zoe and Amelie

Würde man ohne die Schlüsselworte async/await arbeiten, wäre die Ausgabe auf der Konsole undefined, da nicht auf den Rückgabewert des Promises gewartet wird.

function getPromise(): Promise<string> {
  return new Promise<string>((resolve, reject) => {
    resolve("Hello Zoe and Amelie");
  });
}

async function afunc() {
  let res = await getPromise();
  console.log(res);
}

Fazit

TypeScript hat sich über die letzten Jahre zu einer echten Alternative für JavaScript entwickelt. Das JavaScript für Backend-Entwickler punktet mit optionaler statischer Typisierung und einem schnellen intelligenten Compiler. Zusätzlich ist mit Interfaces und Modifiern echte objektorientierte Entwicklung möglich. Das alles prädestiniert es für den Einsatz in großen Projekten. Abgerundet wird es durch die Bereitstellung von Features aus zukünftigen JavaScript-Versionen, die größtenteils zu ECMAScript 3/5 transpiliert werden können und damit voll abwärtskompatibel zum JavaScript-Sprachstandard sind.

Geschrieben von
Johannes Dienst
Johannes Dienst
Johannes Dienst ist Clean Coder aus Leidenschaft bei der MULTA MEDIO Informationssysteme AG. Seine Tätigkeitsschwerpunkte sind die Wartung und Gestaltung von serverseitigen Java- und JavaScript-basierten Applikationen. Twitter: @JohannesDienst
Kommentare

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.