React-Anwendungen können nicht nur in JavaScript, sondern auch in TypeScript entwickelt werden. TypeScript baut auf JavaScript auf und ergänzt die Sprache um ein statisches Typsystem. Dadurch sollen typische Probleme, die mit dem dynamischem Typsystem von JavaScript entstehen, vermieden werden. Dazu gehören insbesondere die bessere Wartbarkeit von Code in größeren und langlebigen Anwendungen.
Bevor wir uns nun ansehen, wie React-Komponenten typsicher mit TypeScript gebaut werden können, werfen wir zunächst einen Blick auf das Typsystem von JavaScript, um die Motivation hinter TypeScript zu verstehen, sowie auf die Grundlagen von TypeScript selbst.
Der folgende Code zeigt die dynamische Natur des Typsystems von JavaScript. Er ist syntaktisch gültig und könnte ohne Fehler im Browser ausgeführt werden.
let person = "Susi";
console.log(typeof person); // Ausgabe: string
console.log(person.toUpperCase()); // Ausgabe: SUSI
person = 32;
console.log(typeof person); // Ausgabe: number
console.log(person + 1); // Ausgabe: 33
person = function() { return "Kate" }
console.log(typeof person); // Ausgabe: function
console.log(person()); // Ausgabe: Kate
Mit dem typeof-Operator wird der Typ, den die Variable person zu einem Zeitpunkt hat, auf der Konsole ausgegeben. Wir sehen, dass der Typ der Variable sich automatisch durch die Zuweisung eines Werts anderen Typs ändert.
Während die dynamische Typisierung auf der einen Seite sehr praktisch ist, weil wir uns keine Gedanken um die Typen machen müssen, kann sie auf der anderen Seite fehleranfällig und schwer verständlich sein. In dem kleinen Code-Beispiel oben können wir noch relativ einfach feststellen, von welchem Typ person ist. Spätestens bei größeren Anwendungen wird das aber schwieriger und damit fehlerträchtiger. Eine Hilfe für das Problem durch einen Compiler oder Typ-Checker gibt es nicht. Sehen wir uns noch ein zweites Beispiel an, diesmal eine Funktion:
function greet(person) { ... }
Wenn wir diese Funktion in unserer Anwendung aufrufen möchten, müssen wir wissen, was person sein soll: ein String? Oder ein Objekt? Und falls ja: welche Struktur hat das Objekt? Darf person auf null gesetzt sein, oder ist der Parameter ganz optional?
All dies können wir der Signatur nicht ansehen und sind auf die Dokumentation oder das Lesen des Source-Codes angewiesen. Auf der anderen Seite kann auch die Funktion selbst zur Laufzeit nicht sicher sein, dass ihr nur Parameter übergeben werden, die dem von ihr erwarteten Typ entsprechen. Nehmen wir beispielsweise an, die Funktion würde einen String erwarten, es wird ihr aber fälschlicherweise ein Objekt übergeben. Würde die Anwendung dann ausgeführt und die Funktion aufgerufen, wird erst zur Laufzeit ein „typischer“ Fehler auftreten:
function greet(person) {
// toUpperCase ist auf einem String definiert, aber nicht auf einem Objekt
return person.toUpperCase();
}
greet({
name: "Susi"
})
// Laufzeitfehler: TypeError: p.toUpperCase is not a function
Genau diese Probleme ergeben sich auch bei der Arbeit mit React-Komponenten, die in der Regel als Funktionen implementiert werden, welche als ersten Parameter Properties entgegennehmen können:
import React from "react";
function Hello(props) {
return <h1>Hello, {props.name}</h1>
}
Wir wissen zwar, dass die React API vorschreibt, dass eine Komponente genau diesen einen Parameter für ihre Properties erwartet, können der Komponente aber nicht ansehen, welche Properties erwartet werden und von welchem Typ diese jeweils sind. Und ebensowenig wie im vorherigen JavaScript-Beispiel können wir auch innerhalb der Komponente nicht hundertprozentig sicher sein, dass wir die erwarteten Properties auch tatsächlich in der erwarteten Form übergeben bekommen. (Es gibt mit den React-PropTypes ein Modul für React, mit dem Properties beschrieben werden können, allerdings erfolgt auch hier die Prüfung lediglich zur Laufzeit und hilft nur sehr eingeschränkt während der Entwicklung).
TypeScript
Diese Probleme können – zumindest in großen Teilen – mit einem statischen Typsystem bereits zur Entwicklungszeit unterbunden werden. Prominente Sprachen mit statischem Typsystem sind etwa Java, C# oder C/C++.
Für JavaScript stellt TypeScript ein Typsystem zur Verfügung. Da TypeScript die Syntax von JavaScript übernimmt und lediglich erweitert (aber nicht verändert), ist jeder gültige JavaScript-Code auch gültiger TypeScript-Code. In der einfachsten Form ist die Umwandlung einer Datei mit JavaScript-Code auf TypeScript dadurch erfolgt, dass die Dateiendung von .js auf .ts geändert wird. (In einem TypeScript-fähigen IDE oder Editor kann man auch eine JavaScript-Datei von TypeScript überprüfen lassen, in dem man am Anfang der Datei //@ts-check einfügt).
Das erste, oben gezeigte Beispiel ist also auch gültiger TypeScript-Code. Allerdings würde der Typchecker von TypeScript bereits bei der zweiten Zuweisung eine Fehlermeldung ausgeben:
let person = "Susi";
console.log(typeof name); // Ausgabe: string
name = 32; // TypeScript Fehlermeldung: Type '32' is not assignable to type 'string'
Obwohl der Code sich an dieser Stelle nicht von der JavaScript-Variante unterscheidet, hat TypeScript der Variablen person bereits (implizit) einen Typ zugewiesen, nämlich string. Dieses Verhalten nennt sich Type Inference und bedeutet, dass TypeScript Typen automatisch und selbstständig herleitet, wo sie nicht explizit angegeben wurden. In diesem Fall hat TypeScript für die Variable “person” den Typ string ermittelt und da einem String keine Zahl (Typ number) zugewiesen werden kann, gibt TypeScript in der folgenden Zeile eine entsprechende Fehlermeldung aus.
Neben der impliziten Herleitung von Typen ist es auch möglich (und an einigen Stellen auch erforderlich), Typangaben explizit hinzuschreiben. Parameter von Funktionen etwa müssen immer mit einer Typangabe versehen werden, da TypeScript hier nicht in der Lage ist, die korrekten Typen selbst zu ermitteln. Typ-Angaben werden in TypeScript durch einen Doppelpunkt getrennt hinter eine Variable, ein Funktionsargument oder eine Funktionssignatur (als Rückgabewert der Funktion) geschrieben. Die greet-Funktion aus dem obigen Beispiel könnte in TypeScript nun so aussehen:
function greet(person: string) {
return person.toUpperCase();
}
greet({
name: "Susi"
});
// TypeScript Fehler: Argument of type '{ name: string; }'
// is not assignable to parameter of type 'string'.
const greeting = greet("Susi"); // OK
greeting.toLowerCase(); // OK, greeting ist string und darauf ist die Funktion toLowerCase definiert.
TypeScript stellt hier bereits zur Entwicklungszeit sicher, dass die greet-Funktion nur mit einem String aufgerufen wird. Alle anderen Typen führen zu einem Fehler. Der Rückgabewert der Funktion wird wiederum von TypeScript automatisch hergeleitet, so dass die Variable greeting ebenfalls vom Typ string ist.
Neben den Datentypen wie string, number, boolean sind in TypeScript auch null oder undefined jeweils eigene Typen. Variablen, Funktionsargumente etc., die null oder undefined annehmen können, müssen aus diesem Grund auch extra gekennzeichnet werden. Der Aufruf von greet mit null als Argument (oder ganz ohne Angabe eines Arguments) würde mit einem Fehler von TypeScript quittiert werden. Um anzuzeigen, dass ein Typ auch null oder undefined sein kann, kann ein Typ verwendet werden, der sich aus mehreren einzelnen Typen zusammensetzt (Union Type). Dazu werden einfach mehrere Typen durch den or-Operator (|) getrennt hingeschrieben. Die Signatur der greet-Funktion könnte wie folgt geändert werden, um auch null-Werte zu akzeptieren:
function greet(person: string | null) {
return person.toUpperCase(); // TS Fehler: Object is possibly 'null'
}
Da person nun zur Laufzeit auch null sein kann, gibt es beim Zugriff darauf eine entsprechende Fehlermeldung. Auf diese Weise verhindert TypeScript potentielle Fehlerquellen. Den entsprechenden Code könnten wir wie folgt korrigieren:
function greet(person: string | null) {
if (person === null) {
return "";
}
return person.toUpperCase(); // OK
}
Hier weiß TypeScript nun, dass person in der letzten Zeile der Funktion nur noch vom Typ string sein kann, weil die andere Möglichkeit im if-Zweig davor überprüft wurde. Dieses Feature nennt sich „Type Narrowing“, also „Typ Eingrenzungen“: je nach Programmzweig kann sich der Typ einer Variablen von einer Menge an Typen auf eine Untermenge reduzieren.
Eigene Typen definieren
Neben den primitiven Typen ist es auch möglich, eigene Typen mit TypeScript zu definieren. Damit lassen sich Strukturen von Objekten beschreiben. Beispielsweise könnten wir für eine andere Variante der greet-Funktion ein person-Objekt beschreiben. TypeScript stellt dann sicher, dass nur Aufrufe an die Funktion erlaubt sind, deren Objekte der erforderlichen Struktur entsprechen. Ein eigener Typ kann mit dem Schlüsselwort type definiert werden. Alternativ kann auch das Schlüsselwort interface verwendet werden. Die Unterschiede zwischen interface und type sind marginal und können für die folgenden Beispiele vernachlässigt werden.
In der Definition werden sämtliche Properties (zu denen natürlich auch Funktionen gehören können) des Objektes samt ihrer Typen angeben:
type Person = { name: string, lastname: string };
function greet(person: Person) {
return `Hello, ${person.name} ${person.lastname}`
};
greet({name: "Susi", lastname: "Mueller"}); // OK
greet({name: "Klaus"}); // TS Error: Property ‘lastname’ missing
React-Komponenten mit TypeScript
Das TypeScript Typsystem bietet noch sehr viel mehr Möglichkeiten, aber bereits mit den bis hierher gezeigten Features lassen sich React-Komponenten mit TypeScript entwickeln.
Um TypeScript in eigenen Projekten zu verwenden, muss das Projekt entsprechend konfiguriert sein. Dazu gehört, dass der TypeScript-Compiler verwendet wird und die TypeScript-Typ Deklarationen für React im Projekt eingebunden sind. Am einfachsten geht das, in dem man das Tool create-react-app verwendet, das neben eines JavaScript-basierten auch ein TypeScript-basiertes React Projekt aufsetzen kann. Dazu muss das Argument –template typescript verwendet werden:
npx create-react-app my-app --template typescript
In diesem Projekt kann nun ohne weitere Konfiguration sofort mit der Entwicklung von React-Komponenten in TypeScript begonnen werden. TypeScript unterstützt sogar die in React verwendete Spracherweiterung JSX. Dazu müssen Dateien, die JSX-Code enthalten, allerdings zwingend mit der Endung .tsx benannt werden.
Als Beispiel, wie eine React-Komponente mit TypeScript gebaut werden kann, sehen wir uns folgende einfache Komponente an, die ein Login-Formular mit zwei Input-Feldern und einem Button rendert:
function LoginForm(props) {
const [username, setUsername] = React.useState("");
const [password, setPassword] = React.useState("");
return (
<form>
<h1>{props.title.toUpperCase()}</h1>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.currentTarget.value)}
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
/>
<button onClick={() => props.onLoginClick(username, password)}>
Login
</button>
</form>
);
}
Um die Komponente mit TypeScript zu verwenden, muss zunächst beschrieben werden, wie das props-Argument aussieht, in dem die übergebenen Properties einer Komponente enthalten sind.
Die LoginForm-Komponente erwartet zwei Properties: einmal den Titel für das Formular und eine Callback-Funktion (onLoginClick) die aufgerufen wird, wenn auf den Button geklickt wird. Der onLoginClick-Funktion werden dann die Eingaben aus dem Formular (username und password) übergeben. Genau diese Signatur kann in dem Typen für das props-Objekt angegeben werden:
type LoginFormProps = {
title: string;
onLoginClick(username: string, password: string): void;
};
function LoginForm(pros: LoginFormProps) { ... }
Innerhalb einer React-Komponente dürfen die eingereichten Properties nicht verändert werden. Diesem Umstand kann die Typ-Definition des Property-Typen Rechnung tragen, in dem dort die einzelnen Properties als read-only markiert werden. Das kann man tun, in dem man die Definition des Typs noch mit dem Hilfstyp Readonly umschließt. An der Verwendung des Typs ändert sich nichts.
type LoginFormProps = Readonly<{
title: string;
onLoginClick(username: string, password: string): void;
}>;
An der Verwendung der Komponente ändert sich durch TypeScript auch nichts. Hier wird wie aus JavaScript-basierten React-Anwendungen die JSX-Notation verwendet, um die Komponente einzubinden.
...
<LoginForm
title="Login"
onLoginClick={
(username, password) => console.log(username, password)
}
/>
...
Mit der Angabe der Typ-Information für die Properties der LoginForm-Komponente kann TypeScript nun eine ganze Reihe von Dingen sicherstellen:
- Verwender der Komponente werden gezwungen, die Properties samt Callback-Funktion korrekt anzugeben. Wenn zum Beispiel das title-Property nicht angegeben wird, erhält der Verwender der Komponente einen entsprechenden Fehler bereits während der Entwicklung und zum Beispiel im CI-Build.
- Auch die Komponente kann sich demnach darauf verlassen, dass sie die korrekten Properties übergeben bekommen hat. Sie kann also sicher sein, dass zum Beispiel das title-Property auf jeden Fall ein String ist (und nicht null oder ein anderer Datentyp).
- Die in TypeScript geschriebene Komponente kann auch aus JavaScript-Code aufgerufen werden. Somit steht sie nicht ausschließlich TypeScript-basierten Anwendungen zur Verfügung. Bei der Verwendung in JavaScript-Code sind dann natürlich keine Typ-Checks mehr möglich und die geschilderten Probleme können auftreten.
- Innerhalb der Komponente können wir nicht (versehentlich) auf Properties zugreifen, die es gar nicht gibt. Ein Fehler in der Art props.titel statt props.title löst einen Fehler in TypeScript aus. Außerdem können Properties nicht versehentlich geändert werden (props.title = „Moin moin!“), da wir die Properties als read-only markiert haben.
- Sowohl der Verwender der Komponente als auch innerhalb der Komponente selbst kann eine TypeScript-kompatible IDE oder Editor-Code Completion und andere Hilfen zur Verfügung stellen.
- Innerhalb der Komponente kennt TypeScript den Typ der Properties, weiß also, dass title ein String und nicht null ist (und somit darauf toUpperCase() aufgerufen werden kann). Da auch die Signatur der Callback-Funktion onLoginClick angegeben ist, kann TypeScript bei der Verwendung der Funktion sicherstellen, dass sie mit den korrekten Argumenten aufgerufen wird.
Neben den Properties verwenden wir in der Komponente auch Zustand (State) und zwar für username und password. In der allereinfachsten Form brauchen wir dafür keine explizite Typ-Angabe hinzuschreiben, da TypeScript – aufgrund der Typ-Deklaration für die useState-Funktion – in der Lage ist, den Typ herzuleiten. Aus diesem Grund gibt es auch kein Problem beim Aufruf der onLoginClick-Callback-Funktion: Da TypeScript sowohl für username als auch password den korrekten Typ (string) ermittelt hat, weiß TypeScript folglich auch, dass der Aufruf korrekt ist.
Auf diese Weise sind auch die von useState zurückgelieferten Setter-Funktionen (setUsername, setPassword) korrekt getypt. Folgende Aufrufe dieser Funktionen würden nun zu Fehler führen:
setUsername(null); // TypeScript Fehler: Argument of type
'null' is not assignable to parameter
setUsername(123); // TypeScript Fehler: Argument of type
'123' is not assignable to parameter
Es gibt Fälle, in denen TypeScript den Typ für den Zustand nicht selbständig herleiten kann. Zum Beispiel, wenn der Zustand auch null sein darf oder aus einem komplexen Objekt besteht. In solchen Konstellationen kann auch dem useState-Hook ein Typ-Parameter übergeben werden, der den State beschreibt. Nehmen wir an, der State für den Username kann auch null annehmen, könnten wir schreiben:
const [username, setUsername] = React.useState<string|null>("");
Da username nun in der Komponente null sein kann, zwingt uns TypeScript bei der Verwendung dazu, entsprechende null-Prüfungen vor der Verwendung durchzuführen, damit es zur Laufzeit nicht zu Fehlern kommt.
Das vollständige Beispiel befindet sich in der Online IDE Codesandbox.
React-Anwendungen mit TypeScript – Ausblick
Wir haben gesehen, dass sich React-Komponenten nahtlos mit TypeScript entwickeln lassen. Im besten Fall müssen wir nicht einmal Typ-Definitionen explizit schreiben, profitieren aber schon von TypeScripts statischem Typsystem. Der TypeScript-Support auch in den weiteren React APIs ist sehr gut. Überall ist es ebenfalls möglich, eigene Typen anzugeben. So kann man zum Beispiel beschreiben, wie ein Objekt aussieht, dass mit der React Context API der Anwendung zur Verfügung gestellt wird. Modifikation und Zugriff auf das globale Objekt sind dann typsicher.
Für den useReducer-Hook kann sowohl sichergestellt werden, dass der in der zugehörigen Reducer-Funktion verwaltete Zustand korrekt verwendet wird (beim Verändern innerhalb des Reducers und beim Verwenden innerhalb der Komponente) und dass die Komponente auch nur bekannte und korrekte Actions mittels der dispatch-Funktion auslöst.
Auch große Teile des React-Ökosystems kommen mit TypeScript klar (und sind teilweise sogar in TypeScript implementiert). Testfälle mit Jest und der react-testing-library lassen sich out-of-the-box in TypeScript schreiben und auch der React Router und Redux funktionieren mit TypeScript. Insbesondere in Redux kann TypeScript eine große Hilfe sein, um sicherzustellen, dass Actions, Reducer und Komponenten korrekt zusammenspielen. Dank der Type Inference ist es dabei an vielen Stellen nicht einmal nötig, explizit Typdefinitionen zu schreiben.
- GraphQL: 13 Antworten auf häufig gestellte Fragen - 4. Juni 2024
- React 2024 – SPA oder Fullstack-Anwendung? - 30. November 2023
- React-Anwendungen mit TypeScript entwickeln - 11. Februar 2021