Warum geht der übergebene Typ bei any verloren?

Wenn Sie einen Parameter als any typisieren, prüft TypeScript nichts mehr von dem, was aus der Funktion kommt:

1
2
3
4
5
6
function firstElement(arr: any[]): any {
  return arr[0];
}

const n = firstElement([1, 2, 3]);
n.toUpperCase(); // compiliert, stürzt zur Laufzeit ab

Sie wissen, dass Sie ein Array von Zahlen übergeben haben. Der Compiler weiß es nicht, weil any ihm sagt, nicht mehr hinzusehen. n kommt als any zurück, also compiliert der Aufruf einer String-Methode auf einer Zahl klaglos und wirft beim Ausführen TypeError: n.toUpperCase is not a function.

Ein Generic benennt den Typ, statt ihn zu verwerfen:

1
2
3
4
5
6
7
function firstElement<T>(arr: T[]): T {
  return arr[0];
}

const n = firstElement([1, 2, 3]); // n: number
const s = firstElement(["a", "b"]); // s: string
n.toUpperCase(); // Fehler: Property 'toUpperCase' does not exist on type 'number'

T ist ein Typparameter, ein Platzhalter, der bei jedem Aufruf gefüllt wird. firstElement([1, 2, 3]) füllt T mit number, der Rückgabetyp wird number, und der falsche Aufruf von .toUpperCase() schlägt in Ihrem Editor fehl statt in der Produktion. Sie schreiben die Funktion einmal, und jeder Elementtyp behält seine Prüfung.

Der Rest dieser Anleitung zeigt, wie Sie Generics nutzen, ohne die Signatur schwerer lesbar zu machen als den Bug, den sie verhindern.

Wie leitet TypeScript den Typparameter her?

Sie haben oben nicht firstElement<number>([1, 2, 3]) geschrieben, und das brauchen Sie selten. TypeScript vergleicht das Argument mit der deklarierten Form des Parameters (T[]) und löst T auf. Es ist dieselbe Inferenz, die const x = 5 als number typisiert, nur angewendet auf Typparameter.

Gibt es nichts, woraus sich etwas herleiten lässt, fällt T auf unknown zurück:

1
2
3
4
5
6
function makeEmptyArray<T>(): T[] {
  return [];
}

const arr = makeEmptyArray(); // T ist unknown, hergeleitet als `unknown[]`
const nums = makeEmptyArray<number>(); // expliziter Typ-Parameter, nums: number[]

Hat der Aufruf keinen Wert, aus dem sich etwas herleiten lässt, übergeben Sie das Typargument selbst in spitzen Klammern.

Die Inferenz kann auch schiefgehen, wenn zwei Argumente dasselbe T füllen und sich widersprechen:

1
2
3
4
5
6
function longest<T>(a: T, b: T): T {
  return a;
}

longest([1, 2], "abc");
// Fehler: Argument of type 'number[]' is not assignable to parameter of type '"abc"'

TypeScript wählt einen Kandidaten und meldet das andere Argument als Problem. Das kann zu einer Fehlermeldung führen, die einen Typ nennt, den Sie nie geschrieben haben. Liefert ein generischer Aufruf einen verwirrenden Fehler oder einen unerwarteten Typ, fahren Sie in Ihrem Editor über den Aufruf und lesen Sie, was für T hergeleitet wurde. Das erklärt das Problem meist schneller als das Lesen der Fehlermeldung.

Wie schränken Sie ein Generic mit extends und keyof ein?

Ein uneingeschränktes T kann alles sein. Innerhalb der Funktion können Sie dann nur das tun, was jeder Typ unterstützt – und das ist, abgesehen vom Weiterreichen des Werts, fast nichts. Die meisten echten generischen Funktionen müssen etwas über T voraussetzen, und mit extends drücken Sie das aus:

1
2
3
4
5
6
7
8
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const dev = { name: "Tobias", role: "backend", salary: 95000 };

getProperty(dev, "role"); // "backend", typisiert als string
getProperty(dev, "pay"); // Fehler: Argument of type '"pay"' is not assignable to parameter of type '"name" | "role" | "salary"'

K extends keyof T beschränkt K auf die Property-Namen des übergebenen Objekts, und T[K] liest den Typ dieser Property aus. Die Liste der gültigen Schlüssel kommt vom Objekt an der Aufrufstelle, nicht aus einer festverdrahteten Union. Ein Tippfehler wie "pay" wird so zu einem Compilerfehler statt zu einem stillen undefined.

Die Einschränkung kann auch eine einfache Form sein:

1
2
3
4
5
6
7
8
function logLength<T extends { length: number }>(value: T): T {
  console.log(value.length);
  return value;
}

logLength("hello"); // 5
logLength([1, 2, 3]); // 3
logLength(42); // Fehler: Argument of type 'number' is not assignable to parameter of type '{ length: number; }'

Strings und Arrays haben beide eine length, also bestehen beide. Eine Zahl nicht, und der Aufruf wird abgelehnt, bevor der Funktionskörper überhaupt läuft. Da der Rückgabetyp T ist und nicht { length: number }, gibt Ihnen logLength("hello") trotzdem einen string zurück.

Wie funktionieren generische Interfaces und Klassen?

Ein generisches Interface ist eine Form mit einem Typparameter:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
interface Box<T> {
  value: T;
  unwrap(): T;
}

const numberBox: Box<number> = {
  value: 5,
  unwrap() {
    return this.value;
  },
};

Eine generische Klasse teilt ihren Typparameter über alle Felder und Methoden hinweg:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class Stack<T> {
  private items: T[] = [];

  push(item: T): void {
    this.items.push(item);
  }

  pop(): T | undefined {
    return this.items.pop();
  }

  peek(): T | undefined {
    return this.items[this.items.length - 1];
  }
}

const numbers = new Stack<number>();
numbers.push(1);
numbers.push(2);
numbers.peek(); // 2, typisiert als number | undefined

new Stack<number>() und new Stack<string>() sind beide vollständig typisiert, und die Klasse existiert nur einmal. Dasselbe Muster taucht überall auf, wo Sie Dependency Injection einsetzen: Sie schreiben ein Repository<T> einmal und injizieren dann Repository<User> und Repository<Order>, wo sie gebraucht werden, statt für jede Entität eine fast identische Klasse zu pflegen.

Mehrere Typparameter funktionieren genauso, und jeder kann seine eigene Einschränkung haben:

1
2
3
4
5
function pair<K, V>(key: K, value: V): [K, V] {
  return [key, value];
}

pair("id", 42); // [string, number]

Genauso bildet ein Result<T, E>-Typ mit zwei Parametern einen Erfolgswert und einen Fehlertyp nebeneinander ab. Wenn Sie entscheiden, ob Funktionen werfen oder Fehler als Daten zurückgeben sollen, behandelt Exceptions vs Error Values genau diese Entscheidung.

Wie funktionieren Standard-Typparameter?

Ein Typparameter kann einen Standardwert haben, genau wie ein normaler Funktionsparameter:

1
2
3
4
5
6
class ApiResponse<T = unknown> {
  constructor(public data: T, public status: number) {}
}

const raw = new ApiResponse({ ok: true }, 200); // T hergeleitet als { ok: boolean }
const typed: ApiResponse<{ id: number }> = new ApiResponse({ id: 1 }, 200);

Die Inferenz gewinnt weiterhin, wenn es ein Argument gibt, aus dem sich etwas herleiten lässt, wie raw zeigt. Der Standardwert greift, wenn nichts T füllt, etwa wenn Sie ApiResponse als Typannotation ohne Typargument schreiben. Das macht Standardwerte nützlich für Wrapper wie Response-Typen und Event-Payloads, bei denen sich viele Aufrufstellen nicht um den genauen Datentyp scheren. Nehmen Sie als Standard unknown, nicht any: Das erzwingt trotzdem eine Prüfung, bevor jemand data liest.

Wann sollten Sie einen Union Type statt eines Generics verwenden?

Stellen Sie sich eine Frage, bevor Sie <T> hinzufügen: Hängt der Rückgabetyp davon ab, was übergeben wurde? Gibt die Funktion unabhängig von der Eingabe immer denselben Typ zurück, reicht eine Union, und ein Generic bringt nichts:

1
2
3
4
// hier braucht es kein Generic: der Rückgabetyp folgt der Eingabe nicht
function describe(value: string | number): string {
  return typeof value === "string" ? value.toUpperCase() : value.toFixed(2);
}

firstElement, getProperty und Stack<T> sind anders. Jede gibt etwas zurück, dessen Typ durch die Eingabe bestimmt wird, und genau diese Verbindung zwischen Eingabe und Ausgabe ist es, was ein Generic bringt. Lässt sich die Funktion mit einer Union schreiben, ohne etwas zu verlieren, nehmen Sie die Union.

Typparameter anzuhäufen, weil eine Funktion generisch sein könnte, kostet Lesbarkeit und bringt nichts. Ein Helper mit drei Typparametern, der nur mit string und number aufgerufen wird, ist schwerer zu lesen als zwei Überladungen. Fügen Sie <T> hinzu, wenn ein echter Aufrufer einen durchfließenden Typ braucht, und entfernen Sie es, wenn jede Aufrufstelle denselben konkreten Typ festlegt.

Warum können Sie T nicht zur Laufzeit verwenden?

Typparameter werden zur Compile-Zeit gelöscht. Das ausgeführte JavaScript enthält kein T, also können Sie keines konstruieren, instanceof T prüfen oder danach verzweigen:

1
2
3
4
function create<T>(): T {
  // return new T(); // Fehler: 'T' only refers to a type, but is being used as a value here
  throw new Error("not implemented");
}

Muss eine Funktion ein T bauen oder zur Laufzeit untersuchen, übergeben Sie einen echten Wert als Argument: einen Konstruktor, ein Schema oder ein Typ-Tag. Generics sind ein Vertrag zur Compile-Zeit, keine Absicherung zur Laufzeit, also müssen Daten, die von außerhalb Ihres Programms kommen (eine API-Antwort, JSON.parse), weiterhin validiert werden, selbst wenn sie als ApiResponse<User> typisiert sind.

Um jeden Fehler aus dieser Anleitung selbst zu sehen, installieren Sie TypeScript mit npm, Yarn oder pnpm, je nachdem, was Ihr Projekt nutzt, fügen Sie die Beispiele in eine .ts-Datei ein und führen Sie npx tsc --noEmit --strict aus. Öffnen Sie dann das meistgenutzte any in Ihrem eigenen Code und prüfen Sie, ob ein <T> den Typ durchreichen würde.

Verwandte Artikel