Der pragmatische Ansatz von TypeScript
TypeScript ist eine graduell typisierte syntaktische Spracherweiterung von JavaScript, welche unter anderem Klassen, Schnittstellen (Interface) und Module für die Strukturierung größerer Quelltexte anbietet. Es braucht gute Gründe für die Einführung neuer Sprachen und Technologien in bestehende Umgebungen und Projekten. Wir möchten den Einsatz der Sprache TypeScript dahingehend untersuchen, welche Probleme in Web-Projekten durch dessen Einsatz gelöst werden. Neben dem Design, dem Typ-System und der Tool-Chain der Sprache erfahren Sie, welche Gründe für die Integration von TypeScript sprechen, und welche dagegen.
Alles neu macht der Mai
Die Informatik kann immer noch als eine junge Wissenschaft klassifiziert werden. Das merkt man im beruflichen Alltag an den vielen Trends. In Zahlen ausgedrückt: Wollte man alle beim Packet-Manager npm registrierten JavaScript-Releases verfolgen, hätte man seit der Einführung vor sieben Jahren im Durchschnitt jeden Tag etwa 110 Ankündigungen zu verarbeiten.
Um selbst nicht zu einem "Fashion-Victim" mit kostenintensiver Wartungs-Zukunft zu werden, lohnt es sich, diese Trends aus größerer Distanz zu betrachten. Bedeutet: Den Zusammenhang zu anderen, bestehenden Projekten und Technologien zu erkennen und zu bewerten. In diesem Fall lohnt sich ein Blick auf die Problemfelder von JavaScript-Projekten, ehe man gleich zu einer Lösung wie CoffeeScript, TypeScript, Flow, ScalaJS und ähnlichem greift.
Die typischen JavaScript-Probleme in komplexeren Projekten
Die Sprache JavaScript, eigentlich ECMA-Script, hat die folgenden Probleme:
- Keine Namensräume.
- Kein einheitliches Modul-System.
- Typ-Prüfung findet nur zur Laufzeit statt.
- Das Fehlen einer statischen Typisierung: Hierdurch ist die korrekte Auswahl der Bezeichner bei "Code Completion" erst möglich.
- Ungewöhnliche Konvertierung und Äquivalenzbeziehungen der Typen.
Beispiel: 1 + "1" –› "11" oder [1,5,20,10].sort() –› [1, 10, 20, 5]. - Die kontextsensitive Selbst-Referenzierung mit this.
Ein Modul-System ist verantwortlich für das Registrieren und Laden von Code, idealerweise auch deren Abhängigkeiten. Zwar wurde mit ECMA-Script 2015, also der aktuellen JavaScript-Spezifikation, ein Modulsystem auf Sprachebene beschrieben. Dieses schweigt sich aber darüber aus, wie mit dem Modul-System interagiert wird, bspw. der Registrierung von Modulen. Von Haus aus integriert kein Browser ein Modul-System. Auf Server-Seite implementiert NodeJS die CommonJS-Spezifikation. Daneben existieren noch die Modul-Spezifikationen von ECMA-Script 2015 und die von AMD. Die Grundstruktur eines JavaScript-Projektes richtet sich daher nach dem verwendeten Modul-Ladesystem aus. Oft ist in kleineren Web-Projekten nur der globale Namensraum das einzig verfügbare, anders ausgedrückt: Der Entwickler lädt die Fremd-Bibliotheken, wie JQuery, händisch über selbstgeschriebene Script-Tags. Bei arbeitsteiligen Entwicklungsprozessen ist daher eine Einigung auf das Modul-System zwischen den Parteien unabdingbar.
Ein analoges Problem stellt das Fehlen von Namensräumen in der Sprache dar, welche mit dem Modul-System oder einem Closure umgangen werden kann. Ohne Abgrenzung, werden Abstimmungen zwischen den Beteiligten in einem JavaScript-Projekt hinsichtlich der Namen und Namensgebung, sichtlich erschwert. So kommt es gelegentlich vor, dass im Projekt mehrere genutzte JavaScript-Bibliotheken ihre Funktionalität an die gleiche Variable binden. Das Beheben solcher Konflikte ohne einen Namensraum der Sprache oder ein Modulsystem, ist dann nur mit Hilfe der Bibliotheken selbst möglich, etwa durch die noConflict-Funktionalität bei Bootstrap oder jQuery [1].
Ein weiterer wichtiger Baustein bei der Entwicklung, ist die Quelltext-Navigation und die Autocompletion der IDEs. Diese analysieren den Kontext eines unvollständigen Ausdrucks am Cursor und suchen für den Entwickler mögliche gültige Ausdrücke. Diese Suche kann mithilfe statistischer Modelle der Programmiersprache [2], einem Index aller Ausdrücke, der Historie [3] und des Typ-Systems der Sprache unterstützt werden. Leider ist nicht bekannt, inwiefern eine moderne JavaScript IDE das erste Verfahren anwendet. Da JavaScript dynamisch typisiert ist, können die IDEs nur eine beschränkte Navigation und Autocompletion anbieten – in den meisten Fällen ist diese also nutzlos.
Das this-Schlüsselwort von JavaScript sorgt manchmal für Verwirrung. Viele Objektorientierte Programmiersprachen, wie Java, Smalltalk oder Self, haben ein Schlüsselwort, das eine Referenz auf das "eigene" Objekt hält. Nicht so in JavaScript: Diese ist abhängig vom Kontext und sogar von der Schreibweise der Funktion. Je nach Problem und Kontext, treten dann die folgenden Effekte auf:
- In einer Funktion referenziert this.X zu undefined
- this zeigt auf das globale "Window"-Objekt
Typischerweise tritt das Problem bei der Definition von Callbacks auf. Ein kleines Beispiel:
// definition einer "Klasse" function MyClass () { this.notInWindow = 0; this.aFunction = function() { return this; }; } // Erzeugung einer Instanz var aClass = new MyClass(); // wird zu true aClass.aFunction().notInWindow === 0 //wird zu false (["a"].map(aClass.aFunction))[0].notInWindow === 0
Der Ausdruck ["a"] erzeugt ein Array mit einem Element. Der Ausdruck ".map( f: (x: any) => any )" erzeugt eine neues Array, dessen Inhalt durch die übergebene Funktion f erzeugt wird. Wenn also im Ausdruck aClass.aFunction() wie erwartet die Instanz von aClass zurückgegeben wird, scheitert dies im Kontext von Array.prototype.map(). Im letzteren Fall wird nicht die Instanz mit this zurück gegeben, sondern das globale Window-Objekt.
Ein Workaround hierfür ist die Einführung einer Variablen, die den Kontext einfängt:
// Einfangen des Kontexts zum Erzeugungszeitpunkt function MyClass () { var self = this; this.notInWindow = 0; this.aFunction = function() { return self; }; }
Für die Wartung kommt erschwerend hinzu, dass diese Callback-Probleme nur zur Laufzeit festgestellt werden können. Um dem zu entgehen, müssen entsprechend umfangreiche Tests hierfür entwickelt werden.
Nach JavaScript Compilieren
Aufgrund dieser Schwächen neigt der geschulte Informatiker reflexartig dazu, JavaScript als Quellsprache zu verbannen und statt dessen mit einer ihm angenehmeren zu arbeiten, welche keine der erwähnten Schwierigkeiten hat. Allerdings gibt es hierbei einige Pferdefüße zu beachten:
- Es muss je nach verwendetem Typ aus der Quellsprache eine Laufzeit-Umgebung im Browser oder in Node eingebunden werden, die diese in der jeweiligen Umgebung emulieren kann. Als Beispiel könnte BigDecimal genannt werden, die bei der Umwandlung nach JavaScript von der GWT Runtime emuliert wird.
- Interoperabilität mit bestehenden JavaScript-Bibliotheken ist je nach Sprache nicht immer möglich.
- Bei der Entwicklung, speziell bei der Fehlersuche, muss ggf. der Browser emuliert werden, wie das bspw. bei GWT 1 noch der Fall war. Nicht jedes Problem kann jedoch emuliert werden.
- Einige Sprachen lösen keines der oben genannten Grundprobleme, etwa die dynamische Typisierung, und dienen lediglich zur Verbesserung der Syntax, etwa CoffeeScript.
Die aufgeführten Beispiele verbessern die Entwicklung aus einer Quellsprache heraus. Im Falle von GWT oder ScalaJS bleiben jedoch die Probleme hinsichtlich der Integration bestehender JavaScript-Frameworks ungelöst. Die Ursache ist hier in den unterschiedlichen Typ-Systemen zwischen der Quellsprache und JavaScript als Zielsprache auszumachen. Die pragmatische Alternative zum Einsatz einer neuen Sprache wäre die Erweiterung von JavaScript selbst, um Elemente wie statische Typisierung oder Klassen basierter Vererbung.
TypeScript als pragmatische Erweiterung von JavaScript
Mittlerweile verwenden neue Programmiersprachen häufig eine bestehende Infrastruktur. So setzen Scala, Groovy und Kotlin beispielsweise auf Java bzw. die JVM. Selbst Low-level-Sprachen, wie Rust [4], Julia [5] oder Clang setzen auf den LLVM Backend. Gleichzeitig versuchen diese Sprachen, sich von ihrer Host-Umgebung zu emanzipieren: Neue Bibliotheken, die oft inkompatibel zur bestehenden Infrastruktur sind, werden eingeführt. Zusätzlich bestimmen oft dogmatische Entscheidungen die Entwicklung, obwohl die Wahl einer Programmiersprache durchaus pragmatisch betrachtet werden darf.
TypeScript geht hier bewusst einen anderen Weg. Anders als die obigen Sprachen verfügt TypeScript nicht einmal über eine Standardbibliothek. Man verwendet als TypeScript-Programmierer genau die gleichen Bibliotheken wie als JavaScript-Programmierer. Das Ziel der TypeScript-Erfinder war immer eine 100%ige Kompatibilität zu JavaScript. Bibliotheken die mit TypeScript entwickelt werden, sollen auch immer mit JavaScript genutzt werden können.
Die Sprache TypeScript wurde nach zweijähriger Entwicklungszeit im Oktober 2012 vorgestellt. Gültiger JavaScript-Code ist immer gültiges TypeScript, im Detail: TypeScript setzt auf die ECMAScript-2015-Spezifikation auf. Die Sprache erweitert JavaScript um eine optionale statische Typisierung zur Compile-Zeit, d. h. keine neuen reservierten Bezeichner (keywords) und keine Erweiterung des Kontroll-Flusses. Der Name ist hier also Programm.
Die Macher von TypeScript haben bei der Einführung der graduellen Typisierung aber darauf zu achten, die Interoperabilität mit JavaScript nicht zu brechen. Dies hat Auswirkungen auf die Korrektheit (soundness) des Typensystems.
TypeScript bietet hinsichtlich der optionalen Typisierung die folgenden Features:
- Strukturelle Typisierung
- Typ-Inferenz
- Klassen, Klassen-basierte Vererbung und Dekoratoren
- Typ-Parameter (Generics)
- Abstrakte Schnittstellen-Typen (Interface)
- Zusätzliche Typen: any, void, enums, tuple, Funktions-Typen, Vereingungstypen (Union), Kreuzungs-Typen (Intersection) und Aufzählungstypen (enum)
- Mehrfach-"Vererbung" mit Mix-Ins
- Typ-Aliase
- Namensräume
Die im folgenden beschriebenen Features sollen keinen Abschrieb der TypeScript-Spezifikation darstellen. Vielmehr soll gezeigt werden, dass die implementierten Features dazu dienen, einerseits die oben genannten JavaScript-Probleme elegant zu umgehen und andererseits die vollständige Kompatibilität zu JavaScript zu wahren.
Dem aufmerksamen Leser wird nicht entgangen sein, dass ein optionales Typensystem nicht äquivalent zu einem graduellen Typensystem ist. Das Typensystem von TypeScript fühlt sich "optional" an und formal betrachtet zählen die Autoren es eher zu den graduellen Typensystemen. Im Kontext dieses Artikels wird optional und graduell synonym verwendet.
Zusammengefasst stellt TypeScript die folgenden Typen zur Verfügung:
- Primitive
- Objekt-Typen
- Union-Typen und Intersection-Typen
- Typ-Parameter
Die folgenden vordefinierten Typen stehen zur Verfügung: any, number, boolean, string, symbol und void. Der any-Typ ist ein so genannter bottom-Typ und erlaubt daher jede beliebige Zuweisung.
var x:any = 0; x = "string"; x = undefined; x = [ true ]; x = { a: undefined };
Wie im Beispiel ersichtlich, orientiert sich die Syntax der Typdeklaration an Sprachen wie Haskell oder ML, also zuerst der Variablen-Name und danach die mit einem Doppelpunkt getrennte optionale Typ-Deklaration.
In JavaScript werden Arrays, Funktionen, Objekte und Konstruktoren nicht als unterschiedliche Arten betrachtet, sondern nehmen die Rollen immer gleichzeitig ein. Eine Funktion ist ein Objekt dem weitere Member hinzugefügt werden können und kann gleichzeitig als Konstruktor für ein Objekt dienen. Die Autoren von TypeScript übernehmen diese Eigenschaft der einheitlichen Objektsicht. Ein Typ in TypeScript kann nicht nur einen Member einer Klasse beschreiben, sondern kann auch in Konstruktoren und Aufrufen verwendet werden.
Struktureller Typ vs. Nomineller Typ
Eine Zuweisung ist nur dann gültig, wenn der Typ aus der Zuweisung in den Typ des Ziels überführt werden kann. Die Typen können hierbei nominell oder strukturell verglichen werden. Beim nominellen Vergleich werden die Typen anhand ihres Names verglichen. Bei einem strukturellen Vergleich prüft der Compiler nicht die Namen der Typen, sondern nur deren inhaltliche Struktur, also beinhaltete Namen und deren Typen. Das folgende Beispiel verdeutlicht die Erzeugung eines Objekts des Typs NotAMessage und die Zuweisung zu einer Variablen des Typs Message, ohne Vererbung.
interface Message { message: string log: (a: string) => void } class NotAMessage { message: string log = (msg:string) => console.log(msg) } let valid : Message = new NotAMessage()
In einer nominellen Sprache wie C# oder Java wäre diese Zuweisung nicht gültig, da die beiden Typen nominell nicht zugewiesen werden können, etwa durch eine Vererbung. Der TypeScript-Compiler lässt dies zu, da alle Member des Typs NotAMessage auf die Member des Typs message überführt werden können: Der Member message ist in beiden Fällen jeweils ein String, und die Funktion log eine Abbildung der Typen string –› void.
Type-Erasure
Prinzipiell stehen in TypeScript die Typen nur zur Compile-Zeit zur Verfügung. Ein Interface in TypeScript wird also nicht nach JavaScript compiliert. Auch sonst werden dem erzeugten Code keine zusätzlichen Typ-Informationen hinzugefügt.
Damit die Prüfung von untypisierten Variablen möglich ist, führt TypeScript sogenannte Type-Guards ein. Hierfür schränkt der Compiler den Typ eines Ausdrucks nach einer typeof- oder instanceof-Abfrage auf den abgefragten Typ ein. Im folgenden Beispiel ist innerhalb der if-Abfrage dem Compiler klar, dass der Typ der Variablen x nicht any sein kann, sondern eben vom Typ string und gibt für die Zeile drei eine entsprechende Fehlermeldung aus:
var x:any = undefined; if (typeof x === 'string') { console.log( x.unknown ); // COMPILE-FEHLER console.log( x.length ); // o.k. } console.log( x.unknown ) // compile o.k.
Type-Guards wurden im Zusammenhang mit Union-Types in TypeScript 1.4 eingeführt und werden daher in diesem Rahmen im betroffenen Abschnitt näher erläutert.
Typ-Inferenz
Um eine Kompatibilität zu JavaScript zu gewährleisten, darf die optionale statische Typisierung bestehendes JavaScript nicht "brechen". Anders formuliert: Welchen Typ soll der Compiler für einen nicht typisierten JavaScript-Ausruck festlegen? Der Compiler hätte im Prinzip zwei Möglichkeiten. Er kann immer einen allgemeinen Typ verwenden oder diesen aus dem Kontext schließen. TypeScript versucht immer letzeres. Dieser Vorgang wird Typ-Inferenz genannt. Ein einfaches Beispiel:
let n = 3 let x = n + "string" // -> string let y = n + 3 // -> number
TypeScript inferiert die folgenden Typen:
- n ist vom Typ number (wegen der 3)
- x ist vom Typ string, da in JavaScript der Ausdruck 1 + "1" ebenfalls ein string ist
- y ist vom Typ number
TypeScripts Typ-Inferenz funktioniert nicht in allen Fällen, da der Compiler, im Gegensatz zu Facebooks Flow [6], die Ausgangspunkte für die Data-Flow-Analyse nicht übermittelt bekommt. Im folgenden Beispiel erkennt der TypeScript-Compiler, im Gegensatz zu Flow, keinen Fehler:
function logLength(x) { console.log(x.length); } logLength(3); // wird leider akzeptiert
Um die Kompatibilität mit JavaScript nicht zu brechen, werden das Downcasting, die Kovarianz und das Indexing nicht geprüft. Während Umgebungen wie C# oder Java bspw. einen inkompatiblen Downcast zur Laufzeit unterbinden, ist dieser in JavaScript erlaubt. Bei einem Down-Cast wird entgegen einer Typ-Hierarchie zugewiesen. Beispiel:
class Bar {} class Foo extends Bar {} let a: Foo = new Bar() as Foo;
TypeScript muss die letzte Zuweisung zulassen, da ansonsten die Kompatibilität zu JavaScript gebrochen wäre. Ein anderes Beispiel für die Einhaltung der JavaScript Kompatibilität ist der Zugriff über den Index eines Objekts. In JavaScript ist der Zugriff auf ein Attribut über die Punkt-Notation oder über den Index-Namen möglich.
interface NoDictionary {}; interface ADictionary { [index: string]: string; } var dict : ADictionary; var nodict:NoDictionary; dict.foo = 'bar'; // COMPILE FEHLER dict['foo'] = 1; // COMPILE FEHLER dict['foo'] = 'bar'; // o.k. nodict.foo = 1; // COMPILE FEHLER nodict['foo'] = 1; // o.k.
Auf die Variable dict kann über den Index auf string-Werte zugegriffen werten. Der Zugriff in Zeile 9 wird erwartungsgemäß unterbunden. Fehlt dem Typ die Eigenschaft des Index-Zugriffs, so kann dennoch über den Index zugegriffen werden und zwar mit dem Typ any, wie Zeile 13 eindrucksvoll demonstriert.
Union-Types
Jede Programmiersprachen-Community verfügt über einen eigenen Satz von Regeln bzw. Mustern, wie typische Aufgaben mit der Sprache abgebildet werden können. Diese Muster finden sich dann beispielsweise in der API von häufig genutzten Bibliotheken. Daher war ein weiteres Ziel der TypeScript-Erfinder, diese Muster auch in TypeScript abbilden zu können bzw. besonders zu unterstützen. Der folgende Code zeigt die Verwendung der pick-Methode der Lodash-Bibliothek:
_.pick({a: 1, b: 2}, "a"); _.pick({a: 1, b: 2}, ["a", "b"]);
Entscheidend ist, dass der zweite Parameter beim Methodenaufruf in der ersten Zeile ein String ist und in der zweiten Zeile ein String-Array. Dieses Muster ist sehr häufig bei JavaScript-Bibliotheken zu finden. Um dieses Muster im Typensystem von TypeScript auszudrücken, steht der Union-Type zur Verfügung:
function pick(obj: any, params: string|string[])
Der zweite Parameter params ist vom Typ string|string[], einem Union-Type, der sowohl string als auch string[] darstellt. Der TypeScript-Compiler stellt nun sicher, dass innerhalb der Implementierung von pick, zwischen beiden Fällen korrekt unterschieden wird. Andere Programmiersprachen lösen vergleichbare Aufgaben mit Method-Overloading. Da dies jedoch eine Unterstützung zur Laufzeit voraussetzt, kann dies von TypeScript nicht verwendet werden. Wäre das obige Muster also nicht so häufig in JavaScript-Code anzutreffen, gäbe es wahrscheinlich auch keine dedizierte Unterstützung dafür in TypeScript.
In Programmiersprachen wie Haskell oder Scala, wird die Nutzung von Union-Types auch syntaktisch unterstützt, speziell das Pattern-Matching macht den Code hier lesbarer. Letztere sind in TypeScript nicht verfügbar. Damit sind vom Typ abhängige Fallunterscheidungen nur mit typeof- und instanceof-Abfragen möglich. Die Blöcke hinter den Abfragen werden zwar mit Type-Guards gesichert, allerdings waren diese bis zur Einführung von User-Defined Type-Guards in TypeScript 1.4 nicht wieder verwendbar. Für die typsichere Unterscheidung zwischen einem Array und einem String, kann für das obige Beispiel der User-Defined Type-Guard isArray definiert werden:
//User-Defined Type-Guard function isArray(arg: string | string[]): arg is string[] { if (Object.prototype.toString.call(arg) === "[object Array]") { return true } return false; } function pick(obj: any, params: string|string[]) { if ( isArray(params)) { var joined: string = params.join(","); console.log("ist ein Array", joined); } else { var joined: string = params.join(","); // COMPILE FEHLER var replaced: string = params.replace(" ", ","); console.log("ist ein String", replaced) } }
Ein anderes Beispiel für einen Union-Type ist die seit TypeScript 2.0 unterstützte Prüfung auf Abstinenz einer Null-Referenz. Aktiviert man diese Eigenschaft mit der Compiler-Option strictNullChecks, so sind folgende Ausdrücke nicht mehr erlaubt:
let x : string = null; // COMPILE FEHLER
Durch die Compile-Option wird erreicht, dass die Zuweisung von null nur noch mit dem null-Typ möglich ist. Benötigt eine Variable dennoch eine null-Zuweisung, so ist dies mit einem Union-Type immer noch möglich:
let x0: null = null; // o.k. let x1 : string | null = null; // o.k.
Namensräume
TypeScript unterstützt Namensräume einerseits durch das namespace-Schlüsselwort, sogenannte interne Module, und andererseits durch externe Modul-Systeme. TypeScript unterstützt die folgenden externen Modul-Systeme: CommonJS [7], AMD [8], SystemJS, Universal Module Definition [9] und den ECMAScript 2015 (ES6) Standard [10]. TypeScript bleibt an dieser Stelle nichts anderes übrig, als die verschiedenen Formate und Varianten zu unterstützen und bietet mit dem internen Modul-System eine eigene Möglchkeit an.
Beim internen namespace-Modul können über die Compile-Option -outFile mehrere Quelltext-Dateien des gleichen Namensraumes in eine Datei exportiert werden. Mit dem ECMAScript 2015 Modul System ist dies so nicht möglich, da der Standard derart interpretiert wird, dass ein ES6-Modul immer mit exakt einer Datei verknüpft ist.
Im Folgenden definiert der Namensraum logger eine extern zugängliche Funktion, die interne Klasse mit dem Zähler ist nicht sichtbar:
namespace logger { class Logger { private counter = 0; log = (msg:any) => { console.log(this.counter++, msg); } } const logger = new Logger() export const log = (msg:any) => logger.log(msg); }
Ohne ein Modul-System expandiert der TypeScript-Compiler den folgenden Code:
var logger; (function (logger_1) { var Logger = (function () { function Logger() { var _this = this; this.counter = 0; this.log = function (msg) { console.log(_this.counter++, msg); }; } return Logger; }());Module P var logger = new Logger(); logger_1.log = function (msg) { return logger.log(msg); }; })(logger || (logger = {}));
Der Compiler bindet den Namensraum logger in diesem Fall an eine Variable logger. Dieses Muster ist bekannt unter dem Namen JavaScript Module Pattern [11] und wird bspw. in “Learning JavaScript Patterns” von Addy Osmani im Detail erläutert [12].
Durch Verwendung eines der Schlüsselworte import oder export, wird die TypeScript-Datei zu einem externen Modul bei dem sich der Compiler auf die konfigurierten Modul-Lader verlässt. Wird etwa vor dem "namespace" ein export vorgestellt, so erzeugt TypeScript entsprechenden Code für den Modul-Lader – in diesem Fall beispielsweise:
define(["require", "exports"], function (require, exports) { "use strict"; var logger; (function (logger_1) { var Logger = (function () { function Logger() { var _this = this; this.counter = 0; this.log = function (msg) { console.log(_this.counter++, msg); }; } return Logger; }()); var logger = new Logger(); logger_1.log = function (msg) { return logger.log(msg); }; })(logger = exports.logger || (exports.logger = {})); });
Zwischenfazit: TypeScript versucht Namensräume zumindest über ein internes Modulsystem anzubieten. Bei Verwendung eines externen Modulsystems, generiert TypeScript mit der gleichen import/export-Syntax, Code für die unterstützten Modul-Systeme.
Klassenbasierte Vererbung
Die Vererbung über Prototypen in JavaScript, ist für JavaScript-Lernende aus der Schule der klassenbasierten Veerebungslehre eher hinderlich. Es ist daher sehr zu begrüßen, dass in ECMA-Script 2015 [13] – und damit auch in TypeScript – hier mit dem class-Schlüsselwort Abhilfe geschaffen wird. Im nächsten Beispiel werden die folgenden Eigenschaften von TypeScript demonstriert:
- Der Zusammenhang zwischen der Laufzeitumgebung in der Typ-Verifikation zur Compile-Zeit.
- Die Nutzung eines Typ-Parameters.
- Klassenbasierte Vererbung.
- Die Qualität des generierten JavaScript-Codes.
class Hello<T> { constructor(public message: T) {} } class World<T> extends Hello<T> { constructor(x: T) { super(x); } hello() { console.log("message from super is...", this.message); } } let greeter = new World("Hello") // compile error greeter.message = 1
Erwähnenswert ist auch die Semantik des this-Schlüsselworts in diesem Zusammenhang, es bezieht sich auf das polymorphe Klassenattribut message, wie im generierten Code gut ersichtlich ist. TypeScript generiert aus dem obigen Beispiel die folgenden Zeilen:
var __extends = (this && this.__extends) || function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; function __() { this.constructor = d; } d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); }; var Hello = (function () { function Hello(message) { this.message = message; } return Hello; }()); var World = (function (_super) { __extends(World, _super); function World(x) { _super.call(this, x); } World.prototype.hello = function () { console.log("message from super is...", this.message); }; return World; }(Hello)); var greeter = new World("Hello"); // compile error greeter.message = 1;
Während im ersten Beispiel der Compiler Fehlermeldungen erzeugt und der Compile-Vorgang abgebrochen werden könnte, ist der erzeugte JavaScript-Code a. gültig und b. komplett. Sogar Kommentare werden übernommen. Der in TypeScript entdeckte Fehler wurde ebenfalls übersetzt. Unserer Erfahrung nach ist der bisher erzeugte Quelltext immer gut lesbar und scheint sich an den Konventionen aus "Effective JavaScript" [14] zu halten.
Tool-Unterstützung
Unserer Meinung nach ist der jahrzehntelange Erfolg [15] der Sprache Java nicht nur auf die Sprache selbst zurückzuführen, sondern auf die exzellente Tool-Unterstützung. TypeScript liefert neben dem Compiler das Tool tsserver an, welches IDEs und Editoren für die Ermittlung der Fehler und für das Code-Completion einfach einbinden können.
Ein anderer wichtiger Aspekt ist das Einbinden von Fremd-Bibliotheken, also die Interoperabilität mit JavaScript. Der TypeScript-Compiler kann hierfür Definitions-Dateien einlesen, welche die Schnittstellen und Typen einer fremden JavaScript-Bibliothek beschreiben, analog zu einer C/C++-Header-Datei. Da es recht aufwändig ist, diese .d.ts-Dateien zu erstellen, gibt es das von Microsoft unabhängige typings-Projekt, über welches die Typ-Informationen installiert werden können. Alternativ ist der TypeScript-Compiler in der Lage, die Typ-Definitionen aus dem dem node.js-Verzeichnis zu laden. Allerdings ist die Mitarbeit des Paket-Inhabers notwendig, da er die Typ-Definition in der Paket-Informations-Datei (package.json) als solche deklarieren muss. Daneben existiert ab TypeScript 2.0 die Möglichkeit, die Typdefinitionen aus den "scoped package" @types zu nutzen [16]. In diesem Fall wäre die Typdefinition vom eigentlichen npm wieder vom Packet getrennt und könnte dennoch via npm installiert werden.
Praxiserfahrung beim Einsatz
Mitte letzten Jahres haben wir ein bestehendes AngularJS-Kunden-Projekt um TypeScript erweitert. Die Hauptschwierigkeit war das Einbinden des Modul-Loaders SystemJS, ohne dabei die bestehende Struktur zu verändern. Wurde der Code bisher über HTML-Tags geladen, so musste beim Wechsel auf SystemJS ein Weg gefunden werden, wie die nicht modularisierten Quelltexte ebenfalls geladen werden können.
Hat man diese Hürde genommen, kann man die alten Quelltexte belassen und neue mit TypeScript entwickeln. Hierdurch ist auch in größeren Projekten eine schleichende Migration auf TypeScript möglich.
Neben dem Einbinden von SystemJS erweist sich die Fehlersuche im Browser als nicht trivial. Während der Microsoft-Browser "Edge" TypeScript wohl von Haus aus unterstützt, ist man bei den anderen Browsern auf "remote"-Debugging angewiesen, IntelliJ und Chrome unterstützen dies bereits. Die Schwierigkeit liegt hier in der Übermittlung von TypeScript: Oft wird zum Browser nur das compilierte JavaScript übermittelt. In diesem Fall sorgte die Erzeugung von Sourcemaps für Abhilfe.
Das Typensystem kann leider nicht alle Fehler aufdecken. Es kommt durchaus vor, dass der Browser einen anderen Typ als angedacht an den eigenen Quelltext übermittelt. Beispiel: document.getElementById(id).value des Browsers liefert immer einen String zurück. Dies kann übersehen werden und folglich würde der weiterverarbeitende Code einen anderen Typen hierfür verwenden. Das fatale an dieser Situation ist, dass ein in TypeScript geschriebener Test diesen Fehler nicht aufdecken kann, da der Compiler die Nutzung eines anderen Typs unterbindet.
Fazit
Immer wenn der Cursor in unseren Alt-Projekten auf einer reinen JavaScript-Zeile hängen bleibt, neigen wir dazu, die Datei sofort nach TypeScript zu migrieren. Wenn im Projekt die TypeScript-Tool-Chain eingebunden ist, erfolgt die Konvertierung quasi im Handumdrehen. Die Vorteile der statischen Typisierung rechtfertigen den meist geringen Aufwand für die Lösung der typischen JavaScript-Probleme. Diese niedrige Schwelle kann dadurch erklärt werden, dass TypeScript keine eigene Sprache ist, sondern in erster Linie eine Erweiterung von JavaScript darstellt. Die wenigen Probleme, etwa die Fehlersuche in manchen Browsern, sind adressiert und die Hersteller, zumindest Microsoft und Google, gehen diese aktiv an.
TypeScript bleibt beim Design des Typensystems pragmatisch und folgt nicht allen Trends aus dem Bereich der Programmiersprachen. Wer Eigenschaften wie Algebraische Datenstrukturen, Typ-Klassen, Pattern-Matching, Bottom-Types (bspw. None) oder Typ-Konstruktoren sucht, wird bei Sprachen wie Purescript fündig.
Quellen
- noConflict-Funktionalität von jQuiery
- V. Raychev, M. Vechev, und E. Yahav; 2013: Code completion with statistical language models
- R. Robbes und M. Lanza; 2008: How program history can improve code completion, in Automated Software Engineering. ASE 2008. 23rd IEEE/ACM International Conference on, S. 317–326.
- Programiersprache Rust
- Programmiersprache Julia
- Facebook Flow
- CommonJS Modul Spezifikation
- AMD Modul Spezifikation
- Universal Module Definition
- Modul Lade API Dokumentation und Implementation
- JavaScript Module Pattern
- Learning JavaScript Design Patterns von Addy Osmani
- ECMA-Script 2015 Spezifikation
- D. Herman; 2012: Effective JavaScript: 68 Specific Ways to Harness the Power of JavaScript
- Tiobe Index
- @types scoped package
Weitere Informationen:
[17] Interview mit Anders Hejlsberg über TypeScript
[18] V. Raychev, M. Vechev, und E. Yahav; 2013: Code completion with statistical language models, S. 419–428.
[19] Liste von Sprachen/Umgebungen mit JavaScript als Zielplattform
[20] TypeScript-Spezifikation
[21] TypeScript Play
[22] Typings Projekt
[23] ModuleCounts Erik DeBill