Warum ein kompilierender Typ trotzdem falsch sein kann
Ein TypeScript-Typ, der fehlerfrei kompiliert, ist nicht automatisch korrekt. Komplexe generische und bedingte Typen können sich unbemerkt zu any verbreitern, ungültige Formen akzeptieren oder in einem Zweig falsch verengen. Tools wie tsd, expect-type und Vitests expectTypeOf machen genau dieses Verhalten testbar, rein zur Kompilierzeit, ohne dass ein einziger Runtime-Test dafür nötig ist.
Inhaltsverzeichnis
- 1. Warum ein kompilierender Typ trotzdem falsch sein kann
- 2. Type-Level-Tests vs. klassische Unit-Tests
- 3. tsd: expectType und expectError in .test-d.ts-Dateien
- 4. expect-type: framework-unabhängige expectTypeOf-Assertions
- 5. Vitests eingebautes expectTypeOf: Type-Checks ohne Zusatzpaket
- 6. Praxisbeispiel: DeepPartial<T> gegen echte Edge Cases testen
- 7. Fallstricke: strukturelle Kompatibilität statt exakter Gleichheit
- 8. CI-Integration: reine Compile-Zeit-Prüfung ohne Runtime
- 9. tsd, expect-type und Vitest expectTypeOf im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum ein kompilierender Typ trotzdem falsch sein kann
Ein TypeScript-Typ, der ohne Fehler kompiliert, ist noch lange nicht semantisch korrekt. Der Compiler prüft nur, ob eine Zuweisung strukturell zulässig ist, nicht ob ein selbst geschriebener Utility-Type wie DeepPartial oder PickByValue tatsächlich das tut, was sein Name verspricht. Ein falsch konstruierter Conditional Type kann sich unbemerkt zu any verbreitern, eine ungültige Objektform akzeptieren, die er eigentlich ablehnen sollte, oder in einem bedingten Zweig gar nicht verengen. Der Fehler zeigt sich erst dort, wo der Typ verwendet wird, oft weit entfernt von seiner Definition.
Genau dieses Risiko wächst mit der Komplexität moderner Typebenen: Mapped Types, Conditional Types, Template Literal Types und rekursive Typen lassen sich beliebig kombinieren, ohne dass ein einzelner fehlerhafter Zweig sofort auffällt. Ein Typ ist letztlich genauso fehleranfälliger Code wie jede Funktion, nur dass er zur Kompilierzeit statt zur Laufzeit läuft. Wer komplexe Typen ohne dedizierte Tests pflegt, verlässt sich auf Zufallsfunde in IDE-Tooltips, statt Regressionen systematisch zu verhindern.
2. Type-Level-Tests vs. klassische Unit-Tests
Ein klassischer Unit-Test führt Code aus und prüft Werte zur Laufzeit: Eine Funktion wird aufgerufen, das Ergebnis mit assert oder expect verglichen. Ein Type-Level-Test tut etwas grundsätzlich anderes: Er führt nichts aus, sondern lässt den TypeScript-Compiler prüfen, ob ein vom Typsystem inferierter Typ exakt dem erwarteten Typ entspricht. Die Assertion existiert nur während der Kompilierung, im gebauten JavaScript ist davon nichts mehr übrig, oft nicht einmal ein Funktionsaufruf.
Dieser Unterschied hat direkte Konsequenzen für die Testabdeckung: Ein Runtime-Test kann nicht erkennen, dass eine generische Funktion für einen bestimmten Eingabetyp plötzlich any zurückgibt, solange der Wert zur Laufzeit trotzdem stimmt. Ein Type-Level-Test erkennt genau das sofort, weil er den inferierten Typ selbst inspiziert, nicht das Laufzeitverhalten. Beide Testarten ergänzen sich: Runtime-Tests sichern das Verhalten ab, Type-Level-Tests sichern die öffentliche Typ-Schnittstelle einer Bibliothek oder eines internen Utility-Types.
3. tsd: expectType und expectError in .test-d.ts-Dateien
tsd ist das etablierteste Werkzeug für Type-Testing in TypeScript-Bibliotheken. Es sucht im Projekt nach Dateien mit der Endung .test-d.ts, kompiliert sie mit dem TypeScript-Compiler und wertet zwei spezielle Funktionen aus: expectType<T>(wert) prüft, ob der inferierte Typ von wert exakt T entspricht, nicht nur kompatibel dazu ist. expectError(ausdruck) markiert eine Zeile, die zwingend einen Compiler-Fehler produzieren muss, damit der Test besteht. Schlägt der erwartete Fehler nicht auf, meldet tsd selbst einen Fehlschlag.
Der entscheidende Vorteil von tsd: Es prüft die tatsächlich veröffentlichten .d.ts-Deklarationsdateien eines Pakets, also genau das, was Konsumenten der Bibliothek zu sehen bekommen. Das macht tsd besonders wertvoll für Bibliotheksautoren, die verhindern wollen, dass ein Refactoring die öffentliche Typ-API unbemerkt verändert. Da tsd komplett über den Compiler läuft, braucht es keinen Test-Runner und keine Runtime-Umgebung, ein einfacher npx tsd-Aufruf genügt.
// deep-partial.ts
// DeepPartial<T>: recursively makes nested properties optional
type DeepPartial<T> = T extends (...args: unknown[]) => unknown
? T // do not recurse into function types, keep them intact
: T extends readonly (infer U)[]
? readonly DeepPartial<U>[]
: T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
export interface UserProfile {
id: string;
address: {
city: string;
zip: string;
};
onSave: () => void;
}
export type { DeepPartial };
// deep-partial.test-d.ts
import { expectType, expectError } from 'tsd';
import type { DeepPartial, UserProfile } from './deep-partial';
const patch: DeepPartial<UserProfile> = {
address: { city: 'Berlin' },
};
// Passing case: a partial nested patch is assignable and typed correctly
expectType<DeepPartial<UserProfile>>(patch);
// Compile error expected: zip must stay a string, never silently become "any"
expectError<DeepPartial<UserProfile>>({ address: { zip: 12345 } });
4. expect-type: framework-unabhängige expectTypeOf-Assertions
expect-type verfolgt einen anderen Ansatz als tsd: Statt separater .test-d.ts-Dateien mit eigener Namenskonvention lassen sich die Assertions direkt in normalen Testdateien oder sogar im Anwendungscode platzieren. Die zentrale API ist expectTypeOf<T>(), verkettbar mit Methoden wie .toEqualTypeOf<U>() für exakte Gleichheit oder .toMatchTypeOf<U>() für reine Zuweisbarkeit. Zur Laufzeit passiert dabei nichts: Alle Aufrufe sind reine Typoperationen ohne Seiteneffekt, der JavaScript-Output enthält im Zweifel nur einen leeren Funktionsaufruf.
Weil expect-type keine eigene Dateikonvention erzwingt, lässt es sich nahtlos in bestehende Jest-, Mocha- oder Vitest-Suiten einbetten, ohne zusätzliche Build-Schritte. Das erleichtert den Einstieg für Teams, die Type-Tests schrittweise neben bestehenden Unit-Tests einführen wollen, statt eine komplett neue Tool-Kette wie bei tsd aufzusetzen. Der Nachteil: Ohne eine aktivierte Typprüfung im Testlauf werden die Assertions stillschweigend übersprungen, dazu mehr im Abschnitt zur CI-Integration.
// pick-by-value.spec.ts
import { expectTypeOf } from 'expect-type';
import type { PickByValue } from './pick-by-value';
interface Flags {
isActive: boolean;
isAdmin: boolean;
label: string;
count: number;
}
type BooleanFlags = PickByValue<Flags, boolean>;
// Correct: exact equality catches extra or missing keys
expectTypeOf<BooleanFlags>().toEqualTypeOf<{ isActive: boolean; isAdmin: boolean }>();
// Pitfall: toMatchTypeOf only checks assignability, so a BooleanFlags type
// that accidentally still contains "label: string" would pass this check too
expectTypeOf<BooleanFlags>().toMatchTypeOf<{ isActive: boolean }>();
5. Vitests eingebautes expectTypeOf: Type-Checks ohne Zusatzpaket
Vitest bringt seit einigen Versionen eine eigene expectTypeOf-API mit, konzeptionell an expect-type angelehnt, aber direkt im Test-Runner integriert. Damit lassen sich Type-Level-Assertions in derselben .test.ts-Datei wie die zugehörigen Runtime-Tests unterbringen, ohne ein zusätzliches Paket zu installieren. Die Assertions selbst werden zur Laufzeit als No-Op behandelt, geprüft werden sie ausschließlich, wenn Vitest im Typecheck-Modus läuft.
Genau dieser Modus ist der wichtigste Stolperstein: Ein normaler vitest run führt die Testdatei aus, ignoriert die expectTypeOf-Aufrufe dabei aber komplett, der Test erscheint grün, obwohl gar keine Typprüfung stattgefunden hat. Erst vitest --typecheck oder eine aktivierte typecheck-Option in der Vitest-Konfiguration startet im Hintergrund einen separaten TypeScript-Prozess, der die Assertions tatsächlich auswertet. Wer das übersieht, hat eine Testsuite, die Sicherheit vortäuscht, ohne sie zu liefern.
// unwrap-promise.test.ts
import { describe, it, expectTypeOf } from 'vitest';
import type { UnwrapPromise } from './unwrap-promise';
describe('UnwrapPromise', () => {
it('unwraps a resolved promise value', () => {
expectTypeOf<UnwrapPromise<Promise<string>>>().toEqualTypeOf<string>();
});
it('leaves non-promise types untouched', () => {
expectTypeOf<UnwrapPromise<number>>().toEqualTypeOf<number>();
});
// Runs only under "vitest --typecheck", a plain "vitest run" silently
// skips this assertion without reporting a failure
});
6. Praxisbeispiel: DeepPartial<T> gegen echte Edge Cases testen
Ein DeepPartial<T>-Utility-Type soll verschachtelte Objekteigenschaften rekursiv optional machen, etwa für Patch-Objekte in einer Update-Funktion. Die naheliegende rekursive Definition über einen Conditional Type sieht harmlos aus, hat aber typische Edge Cases: Funktionseigenschaften dürfen nicht rekursiv aufgebrochen werden, sonst verwandelt sich eine Methode in ein optionales Objekt mit call-signature-artigen Eigenschaften. Arrays brauchen eine eigene Fallunterscheidung, sonst wird aus string[] versehentlich (string | undefined)[] oder schlimmer, any[].
Ein guter Type-Test für DeepPartial deckt deshalb mindestens zwei Fälle ab: einen bestandenen Fall, der zeigt, dass ein verschachteltes Objekt korrekt vollständig optional wird, und einen Fall, der als Compile-Fehler erwartet wird, etwa wenn ein Feld weiterhin einen konkreten Typ wie string statt versehentlich any verlangt. Erst beide Fälle zusammen beweisen, dass der Typ sowohl das Richtige zulässt als auch das Falsche zuverlässig ablehnt.
7. Fallstricke: strukturelle Kompatibilität statt exakter Gleichheit
Der häufigste Fehler beim Type-Testing: Eine Assertion prüft nur, ob ein Typ dem erwarteten Typ zuweisbar ist, nicht ob er ihm exakt entspricht. expectAssignable in tsd oder toMatchTypeOf in expect-type nutzen TypeScripts strukturelle Typisierung aus: Ein zu breiter Typ mit zusätzlichen, eigentlich unerwünschten Eigenschaften besteht den Test trotzdem, weil er kompatibel bleibt. Ein zu enger oder fälschlich auf any verbreiterter Typ fällt mit solchen Checks oft gar nicht auf, weil any zu praktisch jedem Typ zuweisbar ist.
Wer echte Regressionen verhindern will, braucht deshalb expectType beziehungsweise toEqualTypeOf als Standardwerkzeug für invariante Gleichheit, expectAssignable/toMatchTypeOf nur gezielt dort, wo Kompatibilität tatsächlich das Ziel ist. Auch ein einzelnes // @ts-expect-error ohne begleitende Assertion reicht für ernsthafte Testabdeckung nicht aus: Es beweist nur, dass irgendein Fehler in der Zeile auftritt, nicht welcher, und es sagt nichts über den positiven, korrekten Fall aus. Ein verschobener Fehler in einer anderen Zeile lässt den Kommentar unbemerkt seine Wirkung verlieren.
8. CI-Integration: reine Compile-Zeit-Prüfung ohne Runtime
Type-Level-Tests laufen vollständig über den TypeScript-Compiler, nicht über eine JavaScript-Runtime. tsd startet intern einen eigenen tsc-Prozess gegen die konfigurierten .test-d.ts-Dateien, ohne jemals Code auszuführen, was die Tests vergleichsweise schnell macht und unabhängig von Mocking oder Testdaten. Vitest im Typecheck-Modus verhält sich ähnlich: Im Hintergrund läuft ein separater TypeScript-Language-Service-Prozess parallel zum eigentlichen Test-Runner, der Fehler direkt als Testfehlschläge meldet.
In der CI-Pipeline gehören Type-Tests als eigener, expliziter Schritt neben den regulären Unit-Tests: ein npm run test:types-Skript, das tsc --noEmit, tsd und gegebenenfalls vitest --typecheck hintereinander ausführt. Wichtig ist, diesen Schritt nicht optional zu machen, denn ein fehlgeschlagener Type-Test bricht anders als ein Laufzeitfehler nie automatisch den Build ab, wenn er versehentlich übersprungen wird. Gerade bei veröffentlichten npm-Paketen verhindert dieser Schritt, dass eine kaputte öffentliche Typ-API unbemerkt live geht.
#!/usr/bin/env bash
# ci-type-tests.sh: compile-time only, no runtime execution
set -euo pipefail
echo "Running tsc structural check..."
npx tsc --noEmit
echo "Running tsd type tests (*.test-d.ts)..."
npx tsd
echo "Running Vitest type-level assertions..."
npx vitest --typecheck --run
9. tsd, expect-type und Vitest expectTypeOf im Vergleich
Die drei vorgestellten Werkzeuge lösen dieselbe Aufgabe mit unterschiedlichem Integrationsaufwand und unterschiedlicher Strenge. Die folgende Übersicht zeigt, wann sich welches Tool eignet und welcher Fallstrick jeweils typisch ist.
| Tool | Wann einsetzen | Typischer Fallstrick | Ausführung |
|---|---|---|---|
| tsd | Öffentliche .d.ts-API von Bibliotheken absichern | expectError prüft nur "irgendein Fehler", nicht welchen | Läuft über npx tsd, kein Test-Runner nötig |
| expect-type | Assertions direkt in bestehenden Testdateien platzieren | toMatchTypeOf mit toEqualTypeOf verwechselt | Reine Typoperation, kein Laufzeit-Overhead |
| Vitest expectTypeOf | Type-Tests neben Unit-Tests in derselben Datei | Typecheck-Flag vergessen, Test läuft grün ohne Prüfung | Nur aktiv mit vitest --typecheck |
| tsc --noEmit pur | Minimal-Setup ohne zusätzliches Test-Paket | Keine Assertion-API, nur globale Kompilierfehler | Direkt über bestehende tsconfig.json |
| @ts-expect-error allein | Einzelne bekannte Fehlerzeile markieren | Beweist nicht, welcher Fehler auftritt, kein positiver Fall | Kein systematisches Type-Testing möglich |
In der Praxis schließen sich die Werkzeuge nicht aus: Bibliotheksautoren setzen oft auf tsd für die öffentliche .d.ts-Schnittstelle, während interne Utility-Types direkt über Vitests expectTypeOf neben den zugehörigen Unit-Tests abgesichert werden. Entscheidend ist weniger das konkrete Tool als die Disziplin, exakte Gleichheit statt bloßer Kompatibilität zu prüfen und sowohl den positiven als auch den negativen Fall abzudecken.
Mironsoft
TypeScript-Tooling, Type-Safety und CI-Pipelines für Magento- und Headless-Projekte
Typsichere TypeScript-Codebasis aufbauen?
Wir richten Type-Testing mit tsd, expect-type oder Vitest expectTypeOf in eurer Codebasis ein, definieren CI-Gates für kritische Utility-Types und sorgen dafür, dass komplexe Conditional Types nicht unbemerkt kaputtgehen.
Type-Testing-Setup
tsd, expect-type oder Vitest expectTypeOf passend zu eurem Stack einrichten
CI-Pipeline-Integration
Type-Tests als verbindlichen Build-Schritt neben Unit- und E2E-Tests etablieren
Utility-Type-Review
Bestehende Conditional und Mapped Types auf Edge Cases und any-Leaks prüfen
10. Zusammenfassung
Type-Testing löst ein Problem, das klassische Unit-Tests strukturell nicht abdecken können: Ein Typ kann fehlerfrei kompilieren und trotzdem falsch sein, weil er sich zu any verbreitert, eine ungültige Form akzeptiert oder in einem Conditional-Type-Zweig falsch verengt. tsd prüft dafür die öffentlichen .d.ts-Deklarationen einer Bibliothek über eigene .test-d.ts-Dateien und die Funktionen expectType sowie expectError. expect-type und Vitests eingebautes expectTypeOf erlauben dieselbe Art von Assertion direkt in normalen Testdateien, vollständig ohne Laufzeit-Overhead.
Entscheidend für belastbare Type-Tests ist die Wahl der richtigen Assertion: expectType beziehungsweise toEqualTypeOf prüfen exakte Gleichheit und decken damit sowohl zu breite als auch fälschlich auf any verbreiterte Typen zuverlässig auf, während reine Zuweisbarkeits-Checks und ein einsamer @ts-expect-error-Kommentar echte Regressionen oft durchrutschen lassen. In der CI-Pipeline gehören Type-Tests als eigener, nicht optionaler Schritt neben die regulären Unit-Tests, denn nur ein aktiv laufender tsc- oder Typecheck-Prozess deckt Typfehler zuverlässig auf, bevor sie live gehen.
Type-Testing mit tsd und expect-type - Das Wichtigste auf einen Blick
tsd
expectType/expectError in .test-d.ts, prüft die öffentliche .d.ts-API einer Bibliothek exakt.
expect-type
expectTypeOf direkt im Testcode, runtime-frei, ideal für schrittweise Einführung.
Vitest expectTypeOf
Eingebaute Type-Checks, aktiv nur mit vitest --typecheck, sonst stiller No-Op.
Exakte Gleichheit
toEqualTypeOf/expectType statt toMatchTypeOf/expectAssignable nutzen, um any und zu breite Typen zu erkennen.