TypeScript: Typisierungs-Leitfaden

Wann und Wie du typisierst

30.05.2024

typescriptde
Gregor Wedlich
Gregor Wedlich
Life, the Universe and Everything.
Donate with:
Lightning
Alby

Inhaltsverzeichnis:

    Als ich mich an meinen kleinen persönlichen (React) TypeScript-Leitfaden gesetzt habe, wusste ich noch nicht, dass es dazu in der Community eine Diskussion gibt, wie und wann typisiert werden sollte. Da ich mir selbst die Frage auch gestellt habe, möchte ich hier kurz aufzeigen, wie meine Erkenntnisse dazu sind.

    Lass mir gern ein Kommentar da, wenn du es anders siehst!

    Wann solltest du typisieren?

    Verwende Typisierung bei

    1. Objekten oder Arrays
    2. Funktionen und Methoden
    3. Generische Typen: Hooks oder andere generische Funktionen
    4. Externe Daten, z.B. wenn sie von einer API stammen

    Wann du nicht Typisieren musst

    1. Einfache Typen, z.B. const count = 10; da hier TypeScript automatisch ableiten kann das count eine number ist.
    2. Hooks mit Initialwerten z.B. const [isVisible, setIsVisible] = useState(false); der Initialwert gibt hier den Typ an.

    Vorteile der expliziten Typisierung

    Einer der Hauptgründe, der für eine Typisierung spricht, ist in meinen Augen, dass explizite Typen als Dokumentation für deinen Code dienen. Andere Entwickler (oder du selbst in der Zukunft) können sofort sehen, welche Typen erwartet werden. Das schafft Sicherheit und andere können deinen Code besser verstehen.

    Explizite Typen können helfen, Fehler frühzeitig zu erkennen, insbesondere in größeren Codebasen.

    Nachteile der expliziten Typisierung

    Ich gebe zu, ich habe nicht viele Gründe finden können, die gegen eine explizite Typisierung sprechen. Aber ich möchte zumindest einen Gedanken dazu mitgeben.

    1. Das Hinzufügen von Typen zu jedem einzelnen Wert kann den Code unnötig aufblähen und die Verwaltung erschweren.
    2. Wenn du weitere Nachteile kennst, schreibs in die Kommentare :)

    Fazit

    Wie du sicher schon mitbekommen hast, gehöre ich zur Kategorie "Everything needs a type". Ich denke, es ist nicht wirklich zielführend inkonsistent zu typisieren, auch wenn es nicht immer nötig ist, empfinde ich einen konsistenten Weg zielführender als irgendwas dazwischen.

    Wie du typisierst

    Einfache Typen

    Ich denke, das hier ist selbsterklärend.

    1const count: number = 10;

    Funktionen und Methoden

    Bei der Definition von Funktionen und Methoden gibst du die Typen der Parameter und des Rückgabewerts an.

    1function add(a: number, b: number): number { 2 return a + b; 3}

    Generische Typen (Generics)

    Damit ich den Generics gerecht werde muss ich hier etwas tiefer eintauen. Also konzentrieren, es ist ein wichtiger Teil von TypeScript!

    Generics ermöglichen es dir, typsicheren Code zu schreiben, der zur Kompilierzeit überprüft wird. Generics bieten eine flexible Möglichkeit, mit verschiedenen Typen zu arbeiten, ohne den Typ im Voraus festlegen zu müssen. Du kannst generische Funktionen, Klassen und Interfaces erstellen, die mit verschiedenen Typen arbeiten können, ohne den Code duplizieren zu müssen.

    Wenn du generische Typen verwendest, wie bei useRef, useState oder anderen generischen Funktionen gibst du den Type explizit an. Das bedeutet, dass der generische Typ in diesem Fall nicht flexibel ist, sondern auf HTMLInputElement festgelegt wird. Lass uns das genauer betrachten.

    1const inputRef = useRef<HTMLInputElement>(null); 2const [isVisible, setIsVisible] = useState<boolean>(false);

    Der große Vorteil von Generics ist aber wie oben schon erwähnt, dass du mit verschiedene Typen arbeiten kannst, ohne den Typ im Voraus festlegen zu müssen.

    Hee? What?

    Ja du hast richtig gelesen, dass ganze geht zur Laufzeit, daher hier erstmal der Code. Ich erkläre danach, was hier passiert.

    1interface Person { 2 name: string; 3 age: number; 4} 5 6// Die Funktion teilt ein Array von Objekten in mehrere Teile auf 7function splitArray<T>(array: Array<T>, numParts: number): Array<Array<T>> { 8 const result: Array<Array<T>> = []; 9 10 for (let i = 0; i < array.length; i++) { 11 const index = i % numParts; 12 if (!result[index]) { 13 result[index] = []; 14 } 15 result[index].push(array[i]); 16 } 17 18 return result; 19} 20 21const persons: Person[] = [ 22 { name: 'Alice', age: 30 }, 23 { name: 'Bob', age: 25 }, 24 { name: 'Charlie', age: 35 }, 25 { name: 'Diana', age: 28 }, 26]; 27 28const splitPersons = splitArray(persons, 2); 29console.log(splitPersons); 30// Ausgabe: [[{ name: "Alice", age: 30 }, { name: "Charlie", age: 35 }], [{ name: "Bob", age: 25 }, { name: "Diana", age: 28 }]]

    <T>: Der generische Typ T ist ein Platzhalter für den Typ, der zur Laufzeit festgelegt wird.
    array: Array<T>: Der Parameter array ist ein Array von Elementen des Typs T.
    Array<Array<T>>: Die Rückgabe ist ein Array von Arrays, wobei jedes innere Array Elemente des Typs T enthält.

    Du hast noch nicht ganz verstanden, wie wir Typsicherheit erreichen, obwohl wir die Typen nicht vorweg festlegen? Ich versuche es hier an einer Analogie zu erklären.

    Stell dir vor, du hast eine spezielle Box, die alles Mögliche aufnehmen kann: Bücher, Spielzeug, Kleidung usw. Diese Box hat jedoch eine besondere Eigenschaft: Sobald du etwas hineinlegst, kann sie nur noch Dinge desselben Typs aufnehmen. Wenn du also ein Buch hineinlegst, kann die Box nur noch Bücher aufnehmen. Das sorgt dafür, dass du nicht versehentlich ein Spielzeug in eine Box legst, die für Bücher gedacht ist.

    Generics funktionieren ähnlich wie diese Box. Sie ermöglichen es dir, eine Funktion, Klasse oder ein Interface zu definieren, die mit verschiedenen Typen arbeiten kann. Sobald du jedoch einen bestimmten Typ verwendest, sorgt TypeScript dafür, dass nur dieser Typ verwendet wird, was Typsicherheit gewährleistet.

    Ich zeigs hier noch einmal an einem simpleren Beispiel, wir haben hier eine Funktion, die einfach den Wert zurückgibt, den sie erhält.

    1function identity<T>(value: T): T { 2 return value; 3}

    Wir rufen diese Funktion jetzt einfach mit einem String auf.

    1const result1 = identity<string>('Hello, World!'); 2console.log(result1); // Ausgabe: "Hello, World!"

    Wir rufen dieselbe Funktion mit einer Number auf.

    1const result2 = identity<number>(42); 2console.log(result2); // Ausgabe: 42

    TypeScript stellt in jeweils beiden Funktionsaufrufen sicher, dass der Typ entweder vom Type String bei "result1" ist oder von Type Number bei "result2".

    Externe Daten

    Wenn du Daten von einer externen Quelle (z.B. einer API) erhältst, ist es hilfreich, die erwartete Struktur der Daten zu typisieren. Dies geschieht z.B. über ein "interface" würde aber auch anonymisiert oder mit einem Type-Alias gehen. Da gehe ich aber weiter unten noch genauer drauf ein wo da die Unterschiede liegen.

    1interface ApiResponse { 2 data: string; 3 status: number; 4} 5 6async function fetchData(): Promise<ApiResponse> { 7 const response = await fetch('/api/data'); 8 return response.json(); 9}

    DOM-Typen

    Wenn du dir mein Beispiel der Generische Typen genau angeschaut hast wirst du gesehen haben, dass ich bei dem useRef-Hook eine generische Typendefinition angewandt habe, die ich selbst nicht erstellt habe, <HTMLInputElement>. Dies nennt man "DOM-Typen oder DOM-Interfaces". Diese sind Teil der TypeScript-Typdefinitionen, welche mit der TypeScript-Standardbibliothek mitgebracht werden. Diese Typen decken eine Reihe von DOM-Elementen und Schnittstellen ab, die in Webanwendungen verwendet werden.

    Wie finde ich die Typendefinitionen

    Es gibt verschiedene Quellen, welche du nutzen kannst um an die Typendefinitionen zukommen. Du kannst die Dokumentation von TypeScript nutzen oder den Quellcode von TypeScript anschauen, du findest die Typendefinitionen in der Datei lib.dom.d.ts. Der für mich aber beste Weg war bisher über die MDN Web Docs, diese ist eine hervorragende Ressource für Informationen rund um die Web-APIs und der entsprechenden Typen. Beispiel unser "HTMLInputElement"

    Beispielhafte DOM-Typen:

    HTMLInputElement: Repräsentiert ein <input>-Element.
    HTMLButtonElement: Repräsentiert ein <button>-Element.
    HTMLElement: Der Basistyp für alle HTML-Elemente.
    Event: Der Basistyp für alle Ereignisse.

    "Interfaces" oder "Type Aliases"?

    Ein wichtiger Teil von TypeScript sind Interfaces und Type Aliase. Beides ist essenziell, da diese eigentlich fast in jedem Projekt zur Anwendung kommen. Wenn du diese und die Punkte davor kennst und weißt, wie du sie einsetzt, dann hast du 90 % von dem, was du im Alltag brauchst, um mit React und TypeScript zuarbeiten. Zumindest ist es das, was ich bisher am meisten benötigt habe.

    Type-Alias

    Type Alias verwendest du für primitive Typen, Union Types, Intersection Types, Tupel und komplexe Typenkombinationen. Type Aliase könne nicht erweitert werden, aber sie lassen sich kombinieren.

    Union Types (Primitive Typen)

    Ein Union Type kann Typen kombinieren, und ein Wert dieses Typs kann einer der enthaltenen Typen sein.

    Primitive Union Types kombinieren primitive Typen wie string oder number.

    1type ID = string | number; 2 3let userId: ID; 4 5userId = 'abc123'; // OK 6userId = 123; // OK 7userId = true; // Fehler: 'true' ist kein 'string' oder 'number'

    Union Types (Komplexe Types)

    Komplexe Union Types kombinieren komplexe Typen wie Objekte oder Klassen.

    1type SuccessResponse = { 2 status: 'success'; 3 data: any; 4}; 5 6type ErrorResponse = { 7 status: 'error'; 8 error: string; 9}; 10 11type Response = SuccessResponse | ErrorResponse; 12 13function handleResponse(response: Response) { 14 if (response.status === 'success') { 15 console.log('Data:', response.data); 16 } else { 17 console.log('Error:', response.error); 18 } 19} 20 21const success: Response = { status: 'success', data: { id: 1, name: 'Item' } }; 22const error: Response = { status: 'error', error: 'Something went wrong' }; 23 24handleResponse(success); // Data: { id: 1, name: "Item" } 25handleResponse(error); // Error: Something went wrong

    Intersection Types

    Ein Intersection Type kombiniert mehrere Typen zu einem neuen Typ, der alle Eigenschaften der kombinierten Typen enthält. Ein Wert, der einem Intersection Type entspricht, muss alle Eigenschaften der kombinierten Typen haben.

    In dem folgenden Beispiel ist EmployeePerson ein Intersection Type, der alle Eigenschaften von Person und Employee kombiniert. Ein Objekt vom Typ EmployeePerson muss also name, age und employeeId haben.

    1type Person = { 2 name: string; 3 age: number; 4}; 5 6type Employee = { 7 employeeId: number; 8}; 9 10type EmployeePerson = Person & Employee; 11 12const employee: EmployeePerson = { 13 name: 'John Doe', 14 age: 30, 15 employeeId: 1234, 16};

    Tupel

    Wir können mit TypeScript auch Tupel typisieren, also Werte mit einer festen Anzahl von Elementen.

    Hier ein kleines Beispiel, Point erwartet immer zwei Elemente vom Typ number.

    1type Point = [number, number]; 2 3const point: Point = [10, 20]; // Korrekt 4 5const invalidPoint: Point = [10, '20']; // Fehler: Das zweite Element muss eine Zahl sein 6 7const anotherInvalidPoint: Point = [10]; // Fehler: Es müssen genau zwei Elemente sein

    Interfaces

    Ein Interface ist eine Struktur, die beschreibt, wie ein Objekt aussehen soll. Interfaces sind besonders nützlich für die Beschreibung von Objekten und Klassen und unterstützen Vererbung und Deklarationszusammenführung. Interfaces kann nicht für primitive Typen oder Unions Types verwendet werden.

    Objektstrukturen

    So schaut die Struktur eines Objektes aus:

    1interface Person { 2 name: string; 3 age: number; 4}

    Vererbung

    Du kannst bei Interfaces von einem anderen Interface ableiten.

    1interface Person { 2 name: string; 3 age: number; 4} 5 6interface Employee extends Person { 7 employeeId: number; 8} 9 10const employee: Employee = { 11 name: 'John Doe', 12 age: 30, 13 employeeId: 1234, 14};

    Deklarationszusammenführung

    Wenn du mehrere Deklarationen desselben Interfaces haben möchtest, die zu einem einzigen Interface zusammengeführt werden.

    1interface Person { 2 name: string; 3} 4 5interface Person { 6 age: number; 7} 8 9// Das resultierende Interface Person hat sowohl name als auch age 10const person: Person = { name: 'John', age: 30 };

    Ich denke, nun kennst du alles Wichtige zu Interfaces und Type Aliases. Beide Ansätze haben ihre eigenen Stärken und Schwächen, und die Wahl zwischen ihnen hängt oft von den spezifischen Anforderungen deines Projekts ab. Wenn du weitere Fragen hast oder mehr Beispiele benötigst, lass es mich wissen!

    Comments: