Type-Testing: Typen selbst testen mit tsd und expect-type
<T>
type
TypeScript · Type-Testing · tsd · Vitest
Type-Testing: Typen selbst testen mit tsd und expect-type
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.

13 Min. Lesezeit tsd · expect-type · expectTypeOf TypeScript 5.x · Vitest · CI/CD

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.

11. FAQ: Type-Testing mit tsd und expect-type

1Was ist Type-Testing und wie unterscheidet es sich von normalen Unit-Tests?
Prüft, ob ein vom Compiler inferierter Typ exakt dem erwarteten Typ entspricht, ohne Code auszuführen. Unit-Tests laufen zur Laufzeit, Type-Tests komplett innerhalb des Compilers.
2Warum kann ein TypeScript-Typ kompilieren und trotzdem falsch sein?
Der Compiler prüft nur strukturelle Zulässigkeit, nicht semantische Korrektheit. Ein Conditional Type kann sich unbemerkt zu any verbreitern oder falsch verengen.
3Was macht tsd genau und wie sind .test-d.ts-Dateien aufgebaut?
Sucht Dateien mit der Endung .test-d.ts, kompiliert sie mit tsc und wertet expectType- und expectError-Aufrufe gegen die veröffentlichten .d.ts-Deklarationen aus.
4Was ist der Unterschied zwischen expectType und expectError in tsd?
expectType prüft exakte Typgleichheit eines Werts. expectError markiert eine Zeile, die zwingend einen Compiler-Fehler produzieren muss.
5Was bietet expect-type gegenüber tsd?
Keine eigene Dateikonvention, expectTypeOf direkt in bestehenden Jest-, Mocha- oder Vitest-Testdateien einbettbar, alle Assertions sind reine No-Ops zur Laufzeit.
6Wie funktioniert Vitests eingebautes expectTypeOf?
Eigene API im Test-Runner, ausgewertet nur im Typecheck-Modus mit vitest --typecheck, sonst werden die Aufrufe stillschweigend übersprungen.
7Was ist der Unterschied zwischen toEqualTypeOf und toMatchTypeOf?
toEqualTypeOf prüft exakte Gleichheit und erkennt zu breite oder auf any verbreiterte Typen. toMatchTypeOf prüft nur Zuweisbarkeit und lässt unerwünschte Extra-Eigenschaften durch.
8Reicht // @ts-expect-error allein für Type-Tests aus?
Nein. Beweist nur irgendeinen Fehler in der Zeile, nicht welchen, und sagt nichts über den korrekten Fall aus. Zusätzlich expectType oder toEqualTypeOf nötig.
9Wie werden Type-Tests in die CI-Pipeline eingebunden?
Als eigener, nicht optionaler Schritt, z.B. ein npm-Skript mit tsc --noEmit, tsd und vitest --typecheck. Alle drei laufen rein über den Compiler.
10Für welche Art von Code lohnt sich Type-Testing besonders?
Öffentliche Utility-Types, Bibliotheks-APIs, komplexe Conditional/Mapped Types und generische Funktionen mit typabhängigem Rückgabewert, dort passieren any-Leaks am häufigsten.