TypeScript als Alternative zu JavaScript

Tolle Typen
Kommentare

JavaScript ist heutzutage eine der bedeutsamsten Programmiersprachen im Web – daran besteht kein Zweifel. Ebenso wenig ist jedoch zweifelhaft, dass JavaScript nicht gerade zu den einfachen Programmiersprachen gehört. Spätestens wenn es darum geht, komplexe Anwendungen zu erstellen, sollten Sie daher einen Blick auf TypeScript werfen.

Der JavaScript-Aufsatz TypeScript stellt dem Entwickler eine Syntax zur Verfügung, die an andere objektorientierte Sprachen wie C# oder Java erinnert. Der TypeScript-Compiler erzeugt dann das eigentliche JavaScript. Zur Fehlervermeidung übernimmt er außerdem eine Typprüfung: Sind keine Typangaben vorhanden, werden sie per Typinferenz ermittelt. Darüber hinaus erstellt der Compiler IntelliSense-Hinweise für benutzerdefinierte Klassen.

Im Folgenden werde ich mich immer wieder auf Konstrukte wie Klassen und Interfaces beziehen und somit Parallelen zu anderen Sprachen wie C# ziehen. Dennoch sollten Sie sich immer des großen Unterschieds bewusst sein, der zwischen einer stark typisierten Sprache und einer dynamischen Sprache wie JavaScript besteht. Manch augenfällige Übereinstimmung entpuppt sich bei näherem Hinsehen lediglich als begrifflich oder abstrakt konzeptionell.

Klassen

Klassen werden mit dem Schlüsselwort class definiert und können einen Konstruktor, Methoden, Felder und Eigenschaften enthalten. So stellt die Klasse Employee ein stark vereinfachtes Model für die Entität Employee aus Northwind zur Verfügung:

class Employee {
  FirstName;
  LastName;
  BirthDate;
}

Die Klasse hat drei Eigenschaften, deren Typ man ganz JavaScript-typisch nur erahnen kann. Soll der Typ angegeben werden, erfolgt dies durch einen Doppelpunkt getrennt hinter dem zu typisierenden Objekt. TypeScript verfügt über folgende primitive Typen (dazu kommen noch die JavaScript-Typen Date und RegExp):

  • number
  • boolean
  • string
  • Array (generisch)
  • Enum
  • any
  • void

Alle primitiven Typen können zur Typisierung verwendet werden, wobei void natürlich nur bei Methoden eingesetzt werden kann, die keine Rückgabe haben. Auch benutzerdefinierte Typen lassen sich zur Typisierung verwenden. Der nachfolgende Code erweitert unseren ersten Codeschnipsel zum einen um die Typisierung der Eigenschaften LastName, FirstName und BirthDate, zum anderen wird – in Abweichung zur Entität Employee in Northwind – ein Feld vom Typ Employee eingefügt:

class Employee {
  /*Felder*/
  LastName: string;
  FirstName: string;
  BirthDate: Date;
  Boss: Employee;
}

Da die hier vorgenommene Typisierung von JavaScript nicht erkannt wird, hat sie keinerlei Effekt auf den generierten JavaScript-Code. Wozu also das Ganze? Die Typisierung dient dem TypeScript-Compiler zur Typprüfung, zur Bestimmung nicht typisierter Objekte (Type Inference) und zur Erzeugung von Informationen für IntelliSense.

Alle Mitglieder einer Klasse sind automatisch öffentlich. Möchte man eine Kapselung erreichen, muss man den Zugriff auf private setzen. Es hat sich bewährt, den Zugriff auf Felder von außerhalb zu unterbinden und ihn über Eigenschaften (Setter und Getter) zu kontrollieren. Dafür stellt TypeScript die Schlüsselworte get und set zur Verfügung (Listing 1).

class Employee {
  /*Felder*/
  private lastName: string;
  private firstName: string;
  private birthDate: Date;
  /* Eigenschaften */
  get LastName(): string {
    return this.lastName;
  }
  set LastName(value: string) {
    this.lastName = value;
  }
  /*...*/
}

Eine Eigenschaft besteht mindestens aus einem Setter oder einem Getter, die natürlich den gleichen Namen haben müssen. Eine Typisierung ist hier nicht zwingend erforderlich, da TypeScript den Typ des Getters und den Argumenttyp des Setters ermitteln kann:

var employee = new Employee();
employee.FirstName = "Nancy";
employee.LastName = "Davillo";
var fullName = employee.LastName + ", " + employee.FirstName;

Betrachtet man den generierten JavaScript-Code aus Listing 2 näher, fällt auf, dass die Eigenschaft LastName zwei Attribute hat: enumerable und configurable. Das Attribut configurable zeigt an, ob Attribute der Eigenschaft geändert werden können. Enumerable wiederum zeigt an, ob die Eigenschaft via Iteration über alle Eigenschaften des Objekts ausgelesen werden kann. Außerdem kann eine Eigenschaft das Attribut writeable haben – es zeigt an, ob der Wert der Eigenschaft geändert werden kann. Der Wert der Attribute kann in TypeScript allerdings nicht beeinflusst werden.

var Employee = (function () {
    function Employee() {
    }
    Object.defineProperty(Employee.prototype, "LastName", {
        /* Eigenschaften */
        get: function () {
          return this.lastName;
        },
        set: function (value) {
          this.lastName = value;
        },
        enumerable: true,
        configurable: true
    });
    return Employee;
})();

Unsere Klasse ist bislang nichts als ein einfacher Datencontainer. Ihr fehlen noch Methoden und natürlich ein adäquater Konstruktor. Konstruktoren werden in TypeScript mit dem Schlüsselwort constructor definiert:

constructor(lastname: string, firstname: string, birthdate?: Date) {
  this.LastName = lastname;
  this.FirstName = firstname;
  if (birthdate != null) {
    this.BirthDate = birthdate;
  }
}

In TypeScript gibt es keine Möglichkeit, mehrere Konstruktoren zu definieren. Ein wenig Abhilfe kann da die Verwendung von optionalen Parametern schaffen, die nach den obligatorischen Parametern angegeben werden. Nach einem optionalen Parameter darf kein obligatorischer Parameter mehr folgen. Der Konstruktor aus dem obigen Codeschnipsel enthält drei Parameter, wobei lastname und firstname obligatorisch sind. Der Parameter birthdate hingegen ist optional, was durch ein Fragezeichen hinter dem Parameternamen implementiert wird.

Methoden werden durch Angabe ihres Namens und einer Parameterliste definiert. Optional, aber empfehlenswert ist dabei die Angabe des Rückgabetyps. Hat die Methode keine Rückgabe, wird als Rückgabetyp void angegeben. Die nachfolgend aufgeführte Methode AsString soll FirstName und LastName in einem String ausgeben:

AsString(): string {
  return this.FirstName + " " + this.LastName;
}

In JavaScript ist es nicht möglich, mehrere Methoden mit dem gleichen Namen zu definieren, auch wenn ihre Parameterliste unterschiedlich ist. Um dennoch sowas Ähnliches wie Überladungen implementieren zu können, bietet TypeScript die Möglichkeit, eine Methode mit mehreren Methodenköpfen auszustatten:

AsString(format: { format: string;toUpper:boolean }): string;
AsString(format: string): string;
AsString(params: any): string {
  ...
}

Die eigentliche Implementierung erfolgt dann unterhalb des Methodenkopfs, dessen Parametertypen die größte Abstraktion bieten. In diesem Falle ist das AsString(params: any), da jeder Typ auch als Typ any angesehen werden kann. Innerhalb der Implementierung muss dann auf die verschiedenen Möglichkeiten zur Aufrufparametrisierung der Methode eingegangen werden:

AsString(format: { format: string;toUpper:boolean }): string;
AsString(format: string): string;
AsString(params: any): string {
  if (params == undefined) {
    return this.FirstName + " " + this.LastName;
  }
  if (typeof (params) == "string") {
    // ...
  } else {
    // ...
  }
}

Ableitungen

Wo es Klassen gibt, da gibt es natürlich auch Ableitungen. Diese werden mit dem Schlüsselwort extends gebildet. Listing 3 zeigt die Klasse HeadOfDepartment, die von Employee ableitet und dessen Eigenschaften (FirstName, LastName, BirthDate) erbt, aber die Methode IsValid überschreibt. Der Zugriff auf die Basisklasse erfolgt über das Schlüsselwort super. Bei der Überschreibung der Methode IsValid wird mithilfe dieses Schlüsselworts erst die Validierung der Basisklasse aufgerufen und danach die spezifische Validierung der abgeleiteten Klasse durchgeführt.

Hat die abgeleitete Klasse wie in diesem Falle einen Konstruktor, ist der Aufruf des Konstruktors der Basisklasse zwingend erforderlich. Stellt die Basisklasse keinen Konstruktor zur Verfügung, muss dennoch im Konstruktor der abgeleiteten Klasse der Aufruf super()erfolgen.

class HeadOfDepartment extends Employee
{
  private employees: Employee[];
  
  get Employees() {
    return this.employees;
  }
  set Employees(value) {
    this.employees = value;
  }
  constructor(lastname: string, firstname: string, employees?: Employee[], birthdate?: Date)
  {
    super(lastname, firstname, birthdate);
    if (employees != null) {
      this.employees = employees;
    }
  }
 
  IsValid(): boolean {
    if (super.IsValid() == true) {
      return this.employees.length > 0;
    }
    return false;
  }
}

Module

In umfangreichen Anwendungen bleibt es natürlich nicht bei einer Klasse. Zur Gruppierung von Klassen stellt TypeScript das Konstrukt des Moduls zur Verfügung, wobei ein Modul einem Namespace in C# entspricht und durch das Schlüsselwort module und einen Namen definiert wird. Die Mitglieder eines Moduls werden innerhalb des Moduls definiert:

module Model {
  export class Employee {
    // ... 
  } 
}

Module können Klassen, Schnittstellen, Enums, Variablen, Funktionen und weitere Module enthalten. Ohne weiteres Zutun sind sie für die Außenwelt nicht sichtbar. Erst das Schlüsselwort export veröffentlicht ein Modulmitglied. Im einfachsten Fall werden Mitglieder eines Moduls von außerhalb durch Angabe des Modul- und des Mitgliedsnamens (getrennt durch einen Punkt) angesprochen:

var emp = new Model.Employee("Mustermann","Erika");

Interfaces

Schnittstellen werden in TypeScript mit dem Schlüsselwort interface deklariert. Die Schnittstelle der Klasse Employee muss in einem ersten Schritt wie folgt realisiert werden:

interface IEmployee {
  LastName: string;
  FirstName: string;
  BirthDate?: Date;
}

Auch hier findet sich mit BirthDate ein optionales Mitglied – zu erkennen durch das Fragezeichen. Damit wird auch schon der grundlegende Unterschied zwischen den aus C# bekannten Interfaces und denen in TypeScript deutlich: Interfaces in TypeScript sind zwar auch Verträge, die durch eine Klasse implementiert werden können, vielmehr aber beschreiben sie die Gestalt eines Objekts. Dies kann auch durch ein einfaches Objektliteral erfüllt werden. Die nachfolgend dargestellte Funktion erzeugt ein neues Objekt vom Typ Employee und bekommt dafür die Daten aus einem Objekt, dessen Gestalt der Schnittstelle IEmployee entsprechen muss:

function CreateEmployee(source: IEmployee): Employee
{
  var employee = new Employee(source.LastName, source.FirstName, source.BirthDate);
  return employee;
}

Eine Funktion, die mit dieser Funktion wiederum ein Employee namens Erika Mustermann erstellt, könnte wie folgt aussehen:

function ErikaMustermann(): Employee {
  var employee: IEmployee = { LastName: "Mustermann", FirstName: "Erika" };
  return CreateEmployee(employee);
}

Der Umstand, dass ein Interface in TypeScript die Gestalt eines Objekts beschreibt, wird zum großen Vorteil, wenn es etwa darum geht, von einem Server im JSON-Format gelieferte Daten in ein clientseitiges ViewModel aufzunehmen. Schnittstellen können auch durch Klassen implementiert werden. Dazu dient das Schlüsselwort implements. Die nachfolgende Implementierung genügt den Ansprüchen der Schnittstelle IEmployee, obgleich sie das optionale Feld BirthDate nicht enthält:

class SimpleEmployee implements IEmployee {
  LastName: string;
  FirstName: string;
}

Natürlich können Schnittstellen auch Methoden deklarieren, die ebenfalls optional sein können. Die Erweiterung der Schnittstelle IEmployee sieht folgendermaßen aus:

export interface IEmployeeExtended extends IEmployee {
  IsValid: () => boolean;
  AsString?: (format: { format: string; toUpper?: boolean }) => string;
}

IsValid ist eine obligatorische Methode mit einer leeren Parameterliste, die einen Wert vom Typ boolean zurückliefert, was durch => und den Rückgabetyp angegeben wird. AsString ist eine optionale Methode, die einen String zurückliefert und ein Objekt als Parameter übernimmt, dessen Gestalt die Eigenschaften format vom Typ string und toUpper vom Typ boolean aufweisen muss. Die Typisierung für den Parameter format in der Parameterliste ist wiederum ein Interface, allerdings ohne Namen.

Enums

Wer Enums in JavaScript vermisst, kommt dank TypeScript nun auch auf seine Kosten. Mit dem Schlüsselwort enum können Enumerationen definiert werden:

enum Anrede {
  Herr,
  Frau,
  FrauDr,
  HerrDr
}

Der Typ eines Enumerationsmitglieds ist immer number. Bei der beispielhaften Enumeration erfolgt die Vergabe der Werte für die einzelnen Einträge automatisch durch den TypeScript-Compiler, beginnend bei 0. Der Wert für Herr ist also 0, für Frau 1 usw. Allerdings kann man auch selbst bestimmen, welche Werte verwendet werden sollen, wenn diese einfach direkt angegeben werden:

enum Anrede {
  Herr = 1,
  Frau = 2,
  FrauDr = 3,
  HerrDr = 4
}

Hier lauert eine Falle, da vom Compiler nicht reklamiert wird, wenn zwei Enumerationsmitgliedern der gleiche Wert zugewiesen wird. Der qualifizierte Zugriff auf ein Mitglied einer Enumeration (var anredenummer: number = Anrede.Frau) liefert immer den numerischen Wert des Enumerationsmitglieds – in diesem Falle also 1.

Der indizierte Zugriff (var anrede: string = Enums.Anrede[1]) liefert den Namen des Werts der Enumeration zurück – in diesem Falle also Frau. Wird mit einem ungültigen Index auf die Enumeration zugegriffen, dann ist die Rückgabe undefined.

Der umgekehrte Weg – die Ermittlung des Werts eines Enumerationsmitglieds anhand des Namens – ist auch möglich. Mit var anrede: number = Enums.Anrede[„Frau“] kann der Wert für das Mitglied Frau ermittelt werden. Wird anstelle eines gültigen Werts ein Wert angegeben, der nicht Mitglied der Enumeration ist, wird erneut undefined zurückgeliefert.

Auch die Zuweisung eines Werts, der nicht zur Enumeration gehört, ist möglich: var anrede: Enums.Anrede = 42. Der Compiler macht den Entwickler darauf leider nicht aufmerksam und zur Laufzeit passiert in der Hinsicht auch nichts.

Generics

TypeScript kann es auch generisch. Listing 4 zeigt beispielshaft eine verkettete Liste.

module Generics {
  export class ListItem {
    item: any = null;
    next: ListItem = null;
    previous: ListItem = null;
    Add(item: any): ListItem {
      var current = this;
      while (current.next != null) { current = current.next; }
      current.next = new ListItem();
      current.next.previous = current;
      current.next.item = item;
      return current.next;
    }
    Foreach(f: (item: any) => void) {
      if (f != null) {
        var current = this;
        do
        {
          f(current.item);
          current = current.next;
        }while(current != null)
      }
    }
  }
}

Das eigentliche Datenobjekt eines Listeneintrags (item) ist vom Typ any und somit faktisch nicht typisiert. Zur typsicheren Entwicklung empfiehlt es sich, aus ListItem eine generische Klasse zu machen (Listing 5).

module Generics {
  export class ListItem {
    item: T = null;
    next: ListItem = null;
    previous: ListItem = null;
    Add(item: T): ListItem {
      var current = this;
      while (current.next != null) { current = current.next; }
      current.next = new ListItem();
      current.next.previous = current;
      current.next.item = item;
      return current.next;
    }
    Foreach(f: (item: T) => void) {
      if (f != null) {
        var current = this;
        do
        {
          f(current.item);
          current = current.next;
        }while(current != null)
      }
    }
  }
}

Die Syntax für die Erstellung von Generics lehnt sich stark an die von C# an. Der Typparametername wird in spitze Klammern gesetzt – wie im Beispiel . Soll die Möglichkeit der Typisierung eines Generics eingeschränkt werden, kann mit dem Schlüsselwort extends eine Klasse oder ein Interface angegeben werden, dem der eingesetzte Typ zu entsprechen hat. Im Beispiel aus Listing 6 wurde eine Schnittstelle Validate deklariert, deren einzige Methode IsValid():boolean ist.

module Generics {
  export interface Validate {
    IsValid: () => boolean;
  }
  export class ValidatableListItem implements Validate {
    item: T = null;
    next: ValidatableListItem = null;
    previous: ValidatableListItem = null;
    Add(item: T): ValidatableListItem {
      var current = this;
      while (current.next != null) { current = current.next; }
      current.next = new ValidatableListItem();
      current.next.previous = current;
      current.next.item = item;
      return current.next;
    }
    IsValid(): boolean {
      var current = this;
      do {
        if (false == current.item.IsValid()) {
          return false;
        }
        current = current.next;
      }while(current != null)
      return true;
    }
  }
}

Die generische Klasse ValidatableListItem erwartet als Typparameter einen Typen, der die Schnittstelle Validate implementiert oder deren Gestalt entspricht. Durch diese Einschränkung ist es möglich, in der Methode IsValid des Generics dieselbe Methode des beinhalteten Objekts aufzurufen. Generics können nicht nur auf Klassen, sondern auch auf Funktionen und Schnittstellen bezogen werden (Listing 7).

interface ListItemContainer {
  item: T;
  next: ListItemContainer;
  previous: ListItemContainer;
}
interface ValidatableListItemContainer {
  item: T;
  next: ListItemContainer;
  previous: ListItemContainer;
}

Ambient Declarations

Natürlich möchte man bei der Arbeit mit TypeScript nicht auf all die JavaScript-Bibliotheken verzichten, die einem das Leben bislang so einfach gemacht haben. Und das muss man auch nicht: Man muss TypeScript lediglich das API der benötigten Bibliothek über so genannte Ambient Declarations mitteilen. Diese werden in einer Datei mit der Erweiterung .d.ts implementiert. Ambient Declarations kann man mit den Headerdateien aus C und C++ vergleichen.

Hier finden Sie eine JavaScript-Bibliothek zur Erstellung von Charts auf einem HTML-Canvas-Element. Chart.js veröffentlicht ein Klasse namens Chart, die im Konstruktor den Grafikkontext eines Canvas-Elements entgegennimmt und dem Entwickler eine Reihe von Charts zur Darstellung von Geschäftsgrafiken bietet. Mit JavaScript implementiert kann das so aussehen:

var container = document.getElementById("charts").getContext("2d");
var chart = new Chart(container);
var linechart = chart.Line(data, options);

Die Kunst besteht nun darin, TypScript mitzuteilen, dass es eine Klasse Chart mit der Methode Line(…) gibt. Anschließend kann der TypeScript-Compiler JavaScript-Code erzeugen, der dem Einsatz des Chart.js-API im JavaScript-Umfeld entspricht. Dazu werden Interfaces, Klassen und Funktionen deklariert. Im konkreten Fall wird in einer Datei namens chart.d.ts eine Klasse namens Chart deklariert. Das entscheidende Schlüsselwort hierbei ist declare:

declare class Chart {
  constructor(container: any);
  Line: LineChart;
  static defaults: Defaults;
}

Mit dieser Deklaration ist es nun schon möglich, ein Objekt vom Typ Chart zu erstellen: var chart = new Chart(container). Die Deklaration umfasst einen Konstruktor, der das Grafikkontextobjekt entgegennimmt, ein Member namens Line vom Typ LineChart und eine Klassenvariable namens defaults vom Typ Defaults. Die Typen LineChart und Defaults müssen nun auch deklariert werden. Der Typ LineChart ist ein Interface und deklariert eine anonyme Funktion, die einen Parameter vom Typ ChartData und einen vom Typ Options entgegennimmt:

interface LineChart {
  (data:ChartData,options:Options): any;
}

Da die Klasse Chart über ein Member Line vom Typ LineChart verfügt, kann im nächsten Schritt der Aufruf von Line geschrieben werden: var lineChart = chart.Line(data, options). Die Typen ChartData und Options sind wiederum sehr umfangreiche Interfaces.

Bei der Erstellung von Ambient Declarations kommt es letztendlich darauf an, das API einer JavaScript-Bibliothek deklarativ so nachzubilden, dass der vom TypeScript-Compiler erzeugte Code den entsprechenden JavaScript-Code hervorbringt. Natürlich müssen Sie keine Ambient Declarations für beispielsweise jQuery, Knockout.js oder Angular.js erstellen – für diese und viele andere Bibliotheken wurde diese durchaus mühselige Arbeit dankenswerterweise bereits von Anderen erledigt. Möchten Sie allerdings Ihre eigenen Bibliotheken in TypeScript übernehmen, kommen Sie um die Erstellung von Ambient Declarations nicht herum.

Fazit

TypeScript ist eine junge Sprache und vielleicht noch nicht im Hinblick auf jegliche Anwendungsszenarien vollständig, aber dennoch kann man sie bereits ohne Probleme produktiv einsetzen. Vor allem Entwicklern, die sich bisher mit JavaScript eher schwer getan haben, dürften die vertrauten Konzepte und die Typisierung den Einstieg sehr erleichtern.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Meinungen zu diesem Beitrag

X
- Gib Deinen Standort ein -
- or -