navigation Navigation


Inhaltsverzeichnis

Type System


Das TypeScript Type System bildet das Fundament für moderne JavaScript-Entwicklung mit Typsicherheit. Als statisches Typsystem ermöglicht es frühzeitige Fehlererkennung, verbesserte Code-Qualität und präzisere Entwicklerwerkzeuge. TypeScript erweitert JavaScript durch Typannotationen, Interfaces, Generics und fortgeschrittene Typoperationen, ohne die Flexibilität von JavaScript einzuschränken. Während der Kompilierung werden Typinformationen geprüft und anschließend zu standardkonformem JavaScript transformiert, was sowohl Entwicklungseffizienz als auch Laufzeitperformance optimiert. Diese Einführung beleuchtet die grundlegenden Konzepte des TypeScript Type Systems sowie dessen praktischen Nutzen für skalierbare Anwendungen.

Inhaltsverzeichnis

    Was ist ein Type System?

    Ein Type System ist ein Regelwerk, das jedem Ausdruck in einem Programm einen “Typ” zuordnet und prüft, ob diese Typen konsistent verwendet werden.

    Es beantwortet Fragen wie:

    • Welche Operationen sind auf einem Wert erlaubt
    • Welche Werte kann eine Variable enthalten
    • Welche Argumente erwartet eine Funktion

    ** Statisch vs dynamisch typisiert**

    Bei JavaScript ist es erlaubt, die Typen zur Laufzeit zu ändern.

    JavaScript - Dynamisch
    let value = 42; // value ist eine Zahl
    value = "Hello"; // Jetzt ist value ein String
    value = [1, 2, 3]; // Jetzt ist alue ein Array

    In TypeScript kann man den festgelegten Typ nicht ändern.

    TypeScript - Statisch
    let value: number = 42; // value muss eine Zahl sein
    value = "Hello"; // Error: Type 'string' is not assignable to type 'number'

    Type Annotations - Die Basis

    Type Annotations sind explizite Typangaben, die dem Compiler mitteilen, welchen Typ eine Variable, Parameter oder Rückgabewerte haben soll. Die Syntax verwendet einen Doppelpunkt gefolgt vom Typ.

    Die Syntax lautet wie folgt.

    let variablenName: Type = value;
    Beispiel
    let age: number = 25;
    let name: string = "John";
    let isActive: boolean = true;
    
    function greet(name: string): string {
        return `Hello ${name}`;
    }

    Warum Type Annotations verwenden?

    • Explizite Absicht: Man dokumentiert, was der Code tun soll
    • Frühe Fehlererkennung: Typ-Fehler werden sofort erkannt
    • Bessere IDE-Unterstützung: Autocompletion und Refactoring
    • Arbeit im Team: Andere Entwickler verstehen besser die Erwartungen

    Type Inference

    Type Inference ist die Fähigkeit von TypeScript, Typen automatisch abzuleiten, ohne dass man sie explizit angeben muss. Dies macht TypeScript weniger verbose und angenehmer zu schreiben.

    Funktion von Type Inference

    Schauen wir uns ein Beispiel für Verwendung von Type Inference. TypeScript leitet den Typ aus dem Initialwert ab.

    Beispiel
    let count = 0;                  // Typ: number
    let message = "Hello";          // Typ: string
    let isReady = false;            // Typ: boolean
    let nums = [1, 2, 3];           // Typ: number[]
    let mixed = [1, "two", true];   // Typ: (string | number | boolean)[]
    
    function add(a: number, b: number) {
        return a + b; // Rückgabewert Typ: number
    }
    
    const user = {
        name: "John",
        age: 30,
        roles: ["admin", "user"]
    };
    // Typ: { name: string, age: number, roles: string[] }

    Wann sollten Type Annotations verwendet werden?

    Es kann mehrere Gründe geben, warum und wann man Type Annotations verwenden sollte.

    • Funktionsparameter: Immer annotieren für Klarheit
    • Öffentliche APIs: Explizite Typen für Schnittstellen
    • Wenn Inference nicht ausreicht: Bei komplexen Typen
    • Zur Dokumentation: Wenn der Typ nicht offensichtlich ist

    Gute Regel

    Ein guter Weg ist es, die Annotationen immer zu verwenden. So hat man die bestmögliche Qualität und muss nicht nachdenken, ob eine Annotation an bestimmter Stelle sinnig oder sogar notwendig ist.

    // Inference reicht aus
    let simpleText = "Hello";
    
    // Annotation sinnvoll für Klarheit
    let id: string | number = getUserId(); // Macht klar, dass beide Typen möglich sind
    
    // Annotation notwendig
    let values: number[] = []; // Ohne Annotation wäre es 'any[]'

    Strukturelle Typisierung

    TypeScript verwendet strukturelle Typisierung (auch Duck Typing genannt). Das bedeutet, dass die Kompatibiltät von Typen auf ihrer Struktur basiert, nicht auf ihrer Deklaration.

    Prinzip: Wenn es wie eine Ente aussieht und wie eine Ente quakt, dann ist es eine Ente.

    Beispiel
    interface Point {
        x: number;
        y: number;
    }
    
    interface Coordinate {
        x: number;
        y: number;
    }
    
    // Diese Typen sind kompatibel,
    // obwohl sie unterschiedliche Namen haben
    let point: Point = { x: 10, y: 20 };
    let coord: Coordinate = point; // Ok - gleiche Struktur
    
    // Auch ohne explizite Interface-Implementierung
    class Position {
        constructor(public x: number, public y: number) {}
    }
    
    // Ok - Position hat x und y
    let pos: Point = new Position(5, 10);

    Vorteile der strukturellen Typisierung

    • Flexibilität: Code funktioniert mit allen kompatiblen Strukturen
    • Keine explizite Implementierung nötig: Interfaces müssen nicht explizit implementiert werden
    • JavaScript-Kompatibiltät: Passt zu JavaScript dynamischer Natur

    Excess Property Checks

    Bei Objekt-Literalen führt TypeScript zusätzliche Prüfungen durch.

    Beispiel
    interface Person {
        name: string;
        age: number;
    }
    
    // Direkte Zuweisung - Excess Property Check
    let person: Person = {
        name: "John",
        age: 30,
        job: "Developer" // Error: 'job' does not exist in type 'Person'
    };
    
    // Über Variable - kein Excess Property Check
    let someObject = { name: "John", age: 25, job: "Designer" };
    let person2: Person = someObject; // Ok - hat mindestens 'name' und 'age'

    Type Compatibility

    TypeScript prüft Typ-Kompatibiltät basierend auf der Struktur. Folgende Regeln gelten hierbei.

    1. Objekt-Kompatibiltät

    interface Animal {
        name: string;
    }
    
    interface Dog {
        name: string;
        breed: string;
    }
    
    let animal: Animal;
    let dog: Dog = { name: "Buddy", breed: "Golden Retriever" };
    
    // Ok - Dog hat alle Properties von Animal
    animal = dog;
    
    // Fehler - Bei Animal fehlt 'breed'
    // dog = animal;

    2. Funktions-Kompatibiltät

    type Handler = (x: number) => void;
    
    // Weniger Parameter sind OK
    let h1: Handler = (x: number) => console.log(x); // Passt
    let h2: Handler = () => console.log("no params"); // Passt
    let h3: Handler = (x: number, y: number) => console.log(x, y); // Error
    
    // Return-Typ muss kompatibel sein
    type NumberFunc = () => number;
    type StringFunc = () => string;
    
    let nf: NumberFunc = () => 42;
    let sf: StringFunc = () => "Hello";
    
    // nf = sf; // Error - string ist nicht kompatibel mit number

    Type Guards - Typ-Sicherheit zur Laufzeit

    Type Guards sind Ausdrücke, die zur Laufzeit prüfen, welchen Typ ein Wert hat. TypeScript versteht diese Prüfungen und verengt den Typ entsprechend.

    typeof Type Guards

    Hierbei prüft man mit typeof verschiedene Typen und verwendet den Wert dem Typ entsprechend.

    Beispiel
    function processValue(value: string | number) {
        if (typeof value === "string") {
            // In diesem Block ist 'value' ein String
            console.log(value.toUpperCase());
        } else {
            // Hier muss 'value' eine Zahl sein
            console.log(value.toFixed(2));
        }
    }

    instanceof Type Guards

    In diesem Fall wird instanceof verwendet, um den Klassen-Typ zu überprüfen.

    Beispiel
    class Cat {
        meow() { console.log("Meow!"); }
    }
    
    class Dog {
        bark() { console.log("Woof!"); }
    }
    
    function makeSound(animal: Cat | Dog) {
        if (animal instanceof Cat) {
            // Hier ist animal vom Typ 'Cat'
            animal.meow();
        } else {
            // Hier ist animal vom Typ 'Dog'
            animal.bark();
        }
    }

    Custom Type Guards

    Man kann auch eigene Guards definieren. In diesem Beispiel wird die Technik Type Predicate verwendet. Damit möchte man einen Wert auf einen Typ prüfen. Dabei wird nicht nur ein einfacher boolean zurückgegeben, sondern auch gleichzeitig sichergestellt, dass der Wert von einem bestimmten Typ ist.

    Beispiel
    interface Fish {
        swim(): void;
    }
    
    interface Bird {
        fly(): void;
    }
    
    const fish: Fish = {
        swim: () => console.log("Fish swimming");
    };
    
    const bird: Bird = {
        fly: () => console.log("Bird flying");
    };
    
    function isFish(pet: Fish | Bird): pet is Fish {
        return (pet as Fish).swim !== undefined;
    }
    
    function move(pet: Fish | Bird) {
        if (isFish(pet)) {
            pet.swim();
        } else {
            pet.fly();
        }
    }
    
    move(fish);
    move(bird);
    Fish swimming
    Bird flying

    In der Funktion isFish() prüft, ob das Tier eine Methode swim() hat. Wenn ja, kann es nur ein Fisch sein. Wenn nein, ist es ein Vogel.

    Control Flow Analysis

    Control Flow Analysis bezeichnet die Fähigkeit von TypeScript, den tatsächlichen Wert und Typ einer Variable während des Programmablaufs (im Control Flow) zu verfolgen.

    TypeScript analysiert den Pfad, den das Programm nimmt (z.B. über if/else, switch, Type Guards), und passt den Typ einer Variable kontextabhängig an.

    Beispiel - Type Narrowing durch typeof

    Greifen wir das Beispiel von oben auf, da es in diesem Zusammenhang ebenfalls gut passt.

    Beispiel
    function printId(id: number | string) {
        if (typeof id === "string") {
            // Hier ist 'id' ein String
            console.log(id.toUpperCase());
        } else {
            // Hier ist 'id' eine Zahl
            console.log(id.toFixed(2));
        }
    }

    Im if-Block ist id vom Typ string, im else-Block garantiert vom Typ number.


    Beispiel - Type Narrowing durch Null-Check

    Hierbei wird null-Prüfung verwendet, um zu überprüfen, ob ein Wert vorhanden ist. Nach dem Check weiß TypeScript, dass name im if-Block kein null mehr sein kann.

    Beispiel
    function greet(name: string | null) {
        if (name) {
            // name ist ein String
            console.log("Hello, " + name.toUpperCase());
        } else {
            // name ist null
            console.log("Hello, stranger");
        }
    }

    Beispiel - Mehrere Control Flows

    TypeScript kombiniert mehrere Bedingungen und verengt die Typen entlang des gesamten Flusses.

    Beispiel
    function example(x: string | number | boolean | null) {
        if (typeof x === "string") {
            // x ist String
            x.toUpperCase();
        } else if (typeof x === "number") {
            // x ist Zahl
            x.toFixed(2);
        } else if (typeof x === "boolean") {
            // x ist ein boolescher Wert
            x.valueOf();
        } else {
            // x ist null
        }
    }

    Im letzten else kann x NUR noch null sein.