ReasonML: Der mächtige JavaScript-Transpiler im Check
In Web-Umgebungen geht ohne die Programmiersprache JavaScript gar nichts. Dementsprechend beliebt ist sie auch als Zielsprache für Transpiler – also Compiler, die den Quellcode einer Programmiersprache in den Quellcode einer anderen Programmiersprache übersetzen. Entwicklerteams greifen gerne auf Transpiler-Sprachen wie TypeScript, ReasonML und Elm zurück, weil diese Vorteile wie die statische Typisierung, die Nutzung moderner Sprach-Features oder eine bessere Syntax für die funktionale Programmierung mitbringen.
Insbesondere die statische Typisierung sowie die funktionale Programmierung sind hier besonders interessant. Statische Typisierung kann dabei helfen, Fehler frühzeitig aufzudecken, und Compiler können statisch typisierten Code oft besser optimieren. Zudem bieten integrierte Entwicklungsumgebungen (IDE) eine bessere Autovervollständigung an. Funktionale Programmierung hilft mit Konzepten wie Immutability, also unveränderlichen Werten, und dem statuslosen Programmiermodell, Code zu entwickeln, der sich leichter lesen und einfacher warten lässt. An dieser Stelle lohnt sich der Blick auf die Vor- und Nachteile der einzelnen Transpiler-Sprachen, um Entwicklern eine Entscheidungshilfe zu geben, für welches Projekt sich welche Sprache eignet.
Elm orientiert sich eher an Haskell und bringt weitere Konzepte der funktionalen Programmierung mit. Entwickler schätzen die Transpiler-Sprache vor allem für die User-Experience und den Mangel an historischen Altlasten. Zudem überzeugen die Standardbibliotheken mit durchdachten Konzepten und erstaunlicher Konsistenz. Dass Elm seine Sprachkonzepte mit einem Framework zur Web-Entwicklung kombiniert, ist ein weiterer Vorteil. Die sogenannte „Elm-Architektur“ gilt als vorbildlich: Das State-Management hat sich auch außerhalb der Community durchgesetzt und diente beispielsweise als Vorbild für die JavaScript-Bibliothek Redux. Das Standardbeispiel aus dem Elm-Tutorial ist ein Zähler, der sich per Knopfdruck erhöhen oder verringern lässt:
main =
Browser.sandbox { init = 0, update = update, view = view }
type Msg = Increment | Decrement
update msg model =
case msg of
Increment ->
model + 1
Decrement ->
model - 1
view model =
div []
[ button [ onClick Decrement ] [ text "-" ]
, div [] [ text (String.fromInt model) ]
, button [ onClick Increment ] [ text "+" ]
]
Allerdings ist die Interaktion mit bestehenden JavaScript-Bibliotheken und Frameworks für Elm-Apps eher schwierig. Das hat beispielsweise dazu geführt, dass sich der „Backend-as-a-Service“-Anbieter Darklang von Elm abgewandt und seine komplette Plattform nach ReasonML konvertiert hat.
ReasonML – Eine Wiederentdeckung
ReasonML wiederum stammt aus der Feder von React-Framework-Erfinder Jordan Walke. Obwohl die Sprache erst seit 2016 existiert, kümmert sich mittlerweile ein ganzes Team bei Facebook um die Weiterentwicklung der Transpiler-Sprache. Daneben sind auch der Finanzriese Bloomberg sowie ehrenamtliche Enthusiasten aus aller Welt beteiligt. So verwundert es wenig, dass das Projekt auf GitHub fast 9.000 Sterne und 150 Beteiligte hat. Es schneidet regelmäßig gut ab, wenn es um die Entwicklerzufriedenheit geht, wie die Abbildung zeigt.
Kurioserweise ist ReasonML aber gar keine neue Sprache. Das zugrunde liegende OCaml gibt es bereits seit 1996. Walke fiel auf, dass OCaml sehr gut zu React passt, die ungewohnte Syntax jedoch viele Frontend-Entwickler abschreckt. So kam er auf die Idee: Wie wäre es, der Sprache OCaml eine Syntax zu verleihen, die es JavaScript-Entwicklern ermöglicht, sich schnell zurechtzufinden? So lassen sich mit ReasonML die Konzepte einer ausgereiften und funktionalen Programmiersprache mit dem beliebten Hindley-Milner-Typsystem in kürzester Zeit ins Frontend übertragen. ReasonML-Code ist immer zu 100 Prozent typisiert – auf eine Hintertür wie TypeScripts any
oder ts-ignore
wurde bewusst verzichtet. Das erspart auch ein Werkzeug zur Type-Coverage-Analyse.
Und dies funktioniert erstaunlich gut: ReasonML ist für JavaScript- und TypeScript-Entwickler relativ einfach zu erlernen. Die Sprache bringt ein hervorragendes und lückenloses Typsystem mit, eine hohe Performance und Konzepte aus der funktionalen Programmierung. Für eine derart junge Sprache steht außerdem schon ein verhältnismäßig ausgefeiltes Tooling zur Verfügung.
Reasons Transpiler, der sogenannte „BuckleScript-Transpiler, kennt immer den Typ jedes Wertes. Meistens kann BuckleScript einen Typ auch ohne Annotation bestimmen. Sollte das nicht möglich sein, bricht die Kompilierung mit einem Fehler ab. Eine gezielte Annotation ist in wenigen Fällen nötig. BuckleScript stellt so die Disziplin bei der Typisierung sicher und fordert gleichzeitig weniger Typannotationen als TypeScript. Folgende Probleme kann TypeScript beispielsweise nicht erkennen:
type Person = {name: string, vorname: string};
let begruesse1 = (p: Person) => 'Hallo {p.vorname}';
let begruesse2 = (p: {vorname: string}) => 'Hallo {p.vorname}';
let begruesse3 = (p: any) => 'Hallo {p.vorname}';
let begruesse4 = (p) => 'Hallo {p.vorname}'; // Fehler bei --noImplictAny
begruesse1(27); // => Fehler erkannt!
begruesse2(27); // => Fehler erkannt!
begruesse3(27); //OK, sollte aber Fehler sein
begruesse4(27); //OK, sollte aber Fehler sein
Für ReasonML ist das kein Problem, wie Abbildung 2 zeigt. Generell erkennt Reason viele Typprobleme, für die TypeScript zusätzliche Annotationen benötigt. Eine IDE, die Reason per Plugin unterstützt, kann schon während der Entwicklung aktuelle Typen einblenden, ohne dass eine gezielte Annotation nötig ist. Die kleinen grauen Typannotationen über den Zeilen hat VS Code generiert und eingeblendet: In Zeile 6 erkennt die Typinferenz eine Person anhand der Struktur. Der Typ der Funktionen begruesse1
und begruesse4
ist jeweils person => string, das Argument ist also eine Person und der Rückgabewert vom Typ String. Funktion begruesse1
verfügt über eine explizite Annotation für Person. Zeile 10 zeigt mit begruesse4, dass das gar nicht nötig ist, und die Typinferenz es auch ohne Annotation erkennt. So kann Reason den Fehler in Zeile 19 problemlos erkennen.
Um die Fehlererkennung eines Typsystems zu maximieren, ist es wichtig, Typen so zu designen, dass sich gültige Werte im Typsystem darstellen lassen, das Typsystem ungültige aber ablehnt. Im Domain-Driven-Design nennt man das Vermeidung von Invarianten. Natürlich lassen sich leicht Prüfungen programmieren, die Invarianten zur Laufzeit erkennen. Ein gutes Typsystem ermöglicht das aber bereits zur Compile-Zeit.
Ein Beispiel dafür sind Variant Types (Discriminated Union Types in TypeScript).
// VariantExample.re
type farbe = Gruen | Blau | Gelb | Rot;
type form =
| Kreis({radius: int})
| Rechteck({
hoehe: int,
breite: int,
});
let korrekt1 = Kreis({radius: 10});
let korrekt2 = Rechteck({hoehe: 5, breite: 4});
let fehlerErkannt1: form = Rechteck({radius: 10});
let fehlerErkannt2 = Rechteck({radius: 10});
Im Falle von Farbe sieht der Variant Type noch wie ein einfacher Enum aus. Spätestens Form dürfte klarstellen, dass unterschiedliche Varianten von Formen auch unterschiedliche Daten mitbringen können. Eine Form ist entweder ein Kreis oder ein Rechteck. Beide Varianten haben unterschiedliche Daten: Ein Kreis hat den Radius , ein Rechteck Höhe und Breite. Egal, ob mit fehlerErkannt1
oder ohne Typannotation fehlerErkannt2
, ReasonML kann die Fehler erkennen.
In TypeScript funktioniert das ähnlich gut, allerdings ist die Schreibweise ein wenig umständlicher und der Compiler erkennt Fehler nur bei expliziter Typannotation:
// VariantExample.ts
type Kreis = {
type: "Kreis"
radius: number
};
type Rechteck = {
type: "Rechteck"
hoehe: number
breite: number
};
type Form = Kreis | Rechteck;
let fehlerErkannt: Form = {
type: "Rechteck",
radius: 10
};
let fehlerNichtErkannt = {
type: "Rechteck",
radius: 10
};
Funktionale Programmierung
JavaScript hat seine Wurzeln in der funktionalen Programmierung. Mit EcmaScript2015 und Arrow-Funktionen ist der FP-Ansatz in der JavaScript-Community noch einmal deutlich populärer geworden. ReasonML geht auch hier noch ein paar Schritte weiter und bietet viele funktionale Konzepte an. Beispielsweise stellt es mit dem Pipe-Operator |> ein Sprachfeature zur Verfügung, das für JavaScript zumindest schon als Proposal existiert.
Die Komposition von Funktionen ist eines der herausragenden Features der funktionalen Programmierung. Bereits EcmaScript 5 hat Higher-Order-Funktionen wie Map und Filter eingeführt, die andere Funktionen als Parameter entgegennehmen. Mit ES2015 kamen die Arrow-Funktionen als eine wesentliche Syntax-Verbesserung hinzu.
Mit Higher-Order-Funktionen auf dem Array-Objekt lassen sich hervorragend funktionale Pipelines bauen. Aus einer Liste von Wörtern soll beispielsweise ein Stichwortverzeichnis erstellt werden, bei der die Wörter ihrem jeweiligen Anfangsbuchstaben zugeordnet sind. Es sollen aber nur kurze Wörter (weniger als fünf Buchstaben) berücksichtigt werden, und die Ausgabe erfolgt komplett in Großbuchstaben.
Der erste Teil, also das Filtern der kurzen Wörter und das Wandeln in Großbuchstaben lässt sich in JavaScript wie folgt erreichen:
const shortWordsUpperCase = words =>
words.filter(w => w.length < 5).map(s => s.toUpperCase());
In ReasonML sind keine Methoden auf dem Array-Objekt nötig. Stattdessen kann der Pipeline-Operator |> das Ergebnis eines Funktionsaufrufs in den nächsten packen:
let shortWordsUpperCase = words =>
words
|> Array.filter(w => String.length(w) < 5) |> Array.map(String.toUpperCase);
Der Vorteil besteht hier darin, dass das nicht nur für Array-Methoden funktioniert, sondern mit beliebigen Funktionen. Entsprechend lässt sich auch der Rest der Aufgabe mit der Hilfe von Pipelines lösen:
let pair = (letter, words) => (letter, words);
let partitionByFirstLetter = words => {
let letters = [|"A", "B", "C", "D", "E", "F"|];
letters
|> Array.map(l => words |> Array.filter(String.startsWith(l)) |> pair(l));
};
let shortWordsUpperCaseByLetter = ws =>
ws |> shortWordsUpperCase |> partitionByFirstLetter;
Js.log(words |> shortWordsUpperCaseByLetter);
Damit ist die funktionale Programmierung in ReasonML kein radikaler Bruch zu modernem JavaScript – es wirkt eher wie eine konsequente Weiterentwicklung. Das zeigt sich auch an weiteren funktionalen Features wie dem Destructuring und Pattern-Matching, dem Auto-Currying und der Immutability per Default.
Interaktion mit JavaScript
Auch die Interaktion mit bestehendem JavaScript-Code ist für ReasonML kein Problem. Mithilfe einer Reihe von Annotationen kann externer JavaScript-Code nachtypisiert werden. Bei Bibliotheken mit hochdynamischem Verhalten ist das jedoch nicht immer einfach. Im folgenden Beispiel wird die Button-Komponente aus dem CSS-Framework Ant-Design (etwas verkürzt) eingebunden:
[@bs.module "antd/lib/button"] [@react.component]
external make:
(
~onClick: ReactEvent.Mouse.t => unit=?,
~id: string=?,
~className: string=?,
~children: React.element=?,
~size: [@bs.string] [ | 'default | 'middle | 'small]=?,
~_type: [@bs.string] [| 'primary | 'dashed | 'danger] =?
) => React.element = "default";
Bei TypeScript ist dies einfacher, nicht zuletzt, weil das Repository DefinitelyTyped praktisch für sämtliche relevanten JavaScript-Bibliotheken TypeScript-Deklarationen zur Verfügung stellt.
Die hervorragende Kompatibilität zu JavaScript ist das herausragende Feature von TypeScript. Selbst komplexe Typ-Situationen lassen sich mit TypeScript abbilden, etwa Variadic Functions, also Funktionen mit unterschiedlich vielen Argumenten oder Funktionen mit variierendem Rückgabewert. ReasonML holt hier ein wenig auf. Die Anzahl der Bindings auf redex.github.io wächst stetig und die Erstellung eigener Bindings ist zwar nicht einfach, gelingt aber mit neueren BuckleScript-Versionen immer besser.
Tooling Für Einsteiger
Das Tooling von ReasonML ist für eine so junge Sprache erstaunlich gut. Die BuckleScript-Plattform bringt einen Generator zum Erstellen eigener Projekte mit, ähnlich wie der Generator „Create-React-App“. Anhand des Theme-Parameters können Entwickler bestimmen, ob sie etwa ein reines Reason- oder ein ReasonReact-Projekt erstellen wollen. Das ist besonders für Einsteiger hilfreich, die sich nicht mit Compiler-Einstellungen und passender Webpack-Konfiguration beschäftigen wollen. Ist NodeJS installiert, steht nach nur wenigen Befehlen ein lauffähiges Projekt bereit:
npx -p bs-platform bsb -init myproject -theme react-hooks
cd myproject
npm i
Die Plattform bringt mit rmft (kurz für „reformat“) auch gleich ein Formatierungswerkzeug ähnlich wie Prettier für JavaScript mit, sodass überflüssige Syntax-Diskussionen gar nicht erst aufkommen. Eine Reihe von Plugins für bekannte IDE wie VS Code, Intellij oder Emacs vervollständigen das gelungene Tool-Angebot.
Fazit
Die Entscheidung für eine Transpiler-Sprache hängt vom Projekt ab. Wer Wert auf die maximale Kompatibilität mit JavaScript legt oder bestehenden JavaScript-Code mit einem Typsystem anreichern will, sollte sich TypeScript anschauen. Eventuell sind selbst geschriebene Bibliotheken im Einsatz, für die es keine bestehenden Typdefinitionen gibt und die gleichzeitig eine bunte Palette von schwer zu typisierenden API bereitstellen. Hier wäre eine nachträgliche Typisierung mit ReasonML zu aufwendig.
Entwickler, deren Projekte keinerlei Abhängigkeiten zu existierendem JavaScript-Code haben und auch keine Bibliotheken aus dem JavaScript-Ökosystem benötigen, können bedenkenlos zu Elm greifen. Wer die etwas steilere Lernkurve nicht scheut, findet darin eine durchdachte funktionale Sprache, ein hervorragendes Typsystem und eine umfangreiche Standardbibliothek.
ReasonML befindet sich zwischen den beiden Extremen. JavaScript-Bibliotheken, die nicht allzu viele Sonderfälle mitbringen, sind leicht nachzutypisieren. Mit etwas Zusatzaufwand lassen sich auch komplexere Fälle berücksichtigen. Dank der an JavaScript angelehnten Syntax ist ReasonML für eine funktionale Sprache sehr einsteigerfreundlich. Der Hauptvorteil ist ein Typsystem, das trotz überschaubarer Komplexität ein gutes Kosten-/Nutzen-Verhältnis mitbringt. Die konsequente Ausrichtung auf funktionale Programmierung mit der Möglichkeit, notfalls auf klassische Konzepte zurückzugreifen, ermöglicht das Schreiben von verständlichem Code. Der extrem schnelle Compiler und das gut ausgebaute Tooling sorgen für eine Entwicklungserfahrung auf hohem Niveau, das fast an das von Elm heranreicht. Dabei ist es problemlos möglich, auf bestehende JavaScript-Bibliotheken und Frameworks zurückzugreifen.