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.
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.
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;
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.