Module Resolution in TypeScript: node, bundler und mehr
<T>
type
TypeScript · Module Resolution · Vite · Webpack
Module Resolution in TypeScript: node, bundler und mehr
Wie der Compiler eure Importpfade wirklich auflöst

Falsche moduleResolution Einstellungen erzeugen verwirrende Cannot find module Fehler obwohl der Code eigentlich korrekt ist. Dieser Artikel erklärt wie TypeScript unter node node16 und bundler Importpfade auflöst warum Vite und Webpack andere Regeln nutzen als der reine tsc Compiler und wie ihr die passende Strategie für euer tatsächliches Build Tool wählt.

13 Min. Lesezeit moduleResolution · node16 · bundler tsconfig.json · Vite · Webpack

1. Warum "Cannot find module" Fehler entstehen

Kaum eine TypeScript-Fehlermeldung sorgt für so viel Verwirrung wie Cannot find module './utils' or its corresponding type declarations. Der Grund liegt selten am fehlenden Code, sondern fast immer an der falschen moduleResolution-Einstellung in der tsconfig.json. TypeScript trennt strikt zwischen Typprüfung und tatsächlicher Modulauflösung: Der Compiler simuliert, wie ein Importpfad zur Laufzeit aufgelöst würde, und genau diese Simulation folgt unterschiedlichen Algorithmen, je nachdem welche Strategie konfiguriert ist.

Wählt ihr die falsche Strategie, bekommt ihr entweder rote Wellenlinien für Code, der zur Laufzeit tatsächlich funktioniert, oder schlimmer: tsc meldet keinen Fehler, aber der Bundler oder Node.js bricht beim Ausführen ab. Seit TypeScript 5.0 gibt es mit moduleResolution: "bundler" eine dritte Hauptoption neben den klassischen node- und den strikten node16/nodenext-Strategien. Wer versteht, welche Strategie welches Laufzeitverhalten nachbildet, diagnostiziert die meisten Modulauflösungsfehler in Sekunden statt Stunden.

2. Wie Node's CommonJS-Resolution-Algorithmus funktioniert (klassische node-Strategie)

Die Einstellung moduleResolution: "node" (in neueren TypeScript-Versionen auch node10 genannt) bildet den klassischen CommonJS-Algorithmus von Node.js nach, wie ihn require() seit jeher verwendet. Für einen relativen Import wie ./utils prüft der Algorithmus zuerst die exakte Datei, dann dieselbe Datei mit den Endungen .ts, .tsx und .d.ts, danach eine gleichnamige Datei mit .js. Existiert keine passende Datei, wird geprüft, ob ./utils ein Verzeichnis mit einer index-Datei ist.

Für nicht relative Importe wie lodash läuft der Algorithmus den Verzeichnisbaum vom aktuellen Ordner aus Schritt für Schritt nach oben und sucht in jedem übergeordneten node_modules-Ordner, bis er fündig wird oder am Dateisystem-Root ankommt. Dieses Verhalten ist bequem, weil praktisch jeder Importpfad ohne Endung funktioniert, entspricht aber nicht mehr dem strikten ESM-Resolver, den Node.js seit Version 12 für echte .mjs- und "type": "module"-Pakete verwendet.

3. ESM-Resolution und die node16/nodenext-Strategie (package.json exports, Endungspflicht)

Mit moduleResolution: "node16" beziehungsweise "nodenext" bildet TypeScript den tatsächlichen ECMAScript-Module-Resolver von Node.js nach, nicht mehr den alten CommonJS-Algorithmus. Der wichtigste Unterschied in der Praxis: Relative Importe benötigen eine explizite Dateiendung. Ein Import wie ./utils schlägt fehl, während ./utils.js funktioniert, selbst wenn die Quelldatei tatsächlich utils.ts heißt. TypeScript erwartet hier bewusst die Endung, die nach der Kompilierung existieren wird, nicht die Endung der Quelldatei.

Zusätzlich respektiert node16/nodenext das exports-Feld in der package.json eines Pakets und blockiert Importe von Dateien, die dort nicht explizit freigegeben sind, selbst wenn die Datei physisch im node_modules-Ordner existiert. Das Feld "type": "module" in der package.json entscheidet zusätzlich pro Paket, ob .js-Dateien als CommonJS oder als ESM interpretiert werden, was TypeScript ebenfalls für jede einzelne Datei im Projekt nachvollzieht.


// tsconfig.json: "moduleResolution": "node16"

// utils.ts
export function formatPrice(cents: number): string {
  return (cents / 100).toFixed(2);
}

// checkout.ts

// WRONG under node16: missing file extension
import { formatPrice } from './utils';
// error TS2835: Relative import paths need explicit file
// extensions in ECMAScript imports when '--moduleResolution' is 'node16'

// RIGHT under node16: extension refers to the compiled output, not the source file
import { formatPrice } from './utils.js';

4. Die bundler-Strategie (für Vite, Webpack, esbuild entwickelt)

Die mit TypeScript 5.0 eingeführte Einstellung moduleResolution: "bundler" löst ein reales Problem: Weder die alte node-Strategie noch node16/nodenext bilden korrekt ab, wie moderne Bundler Module tatsächlich auflösen. Vite, Webpack und esbuild verlangen keine Dateiendung bei relativen TypeScript-Importen, respektieren aber trotzdem das exports-Feld moderner Pakete. Die bundler-Strategie kombiniert genau diese beiden Eigenschaften und erlaubt ./utils ohne Endung, während sie gleichzeitig moderne package.json-Feldkonventionen versteht.

Wichtig zu verstehen: moduleResolution: "bundler" ist ausschließlich für die Typprüfung gedacht. Es weist tsc an, sich so zu verhalten wie euer tatsächliches Build-Tool, emittiert aber selbst keinen JavaScript-Code für den produktiven Einsatz, wenn ihr Vite oder Webpack für den eigentlichen Build verwendet. In diesem Setup läuft tsc typischerweise nur mit noEmit: true für die Typprüfung, während der Bundler das eigentliche Bundling übernimmt.


// tsconfig.json - for a Vite or Webpack project
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true
  }
}

// tsconfig.json - for a tsc-only CLI build (Node.js ESM output)
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "node16",
    "moduleResolution": "node16",
    "outDir": "dist",
    "declaration": true
  }
}

5. paths und baseUrl im Zusammenspiel mit moduleResolution

Das paths-Feld in der tsconfig.json ist eine reine Kompilierzeit-Abkürzung für den TypeScript-Compiler, keine echte Laufzeit-Umleitung. Ein Eintrag wie "@app/*": ["src/*"] sagt tsc, wo es die Typen für @app/utils findet, erzeugt aber keinerlei Mechanismus, der diesen Alias zur Laufzeit tatsächlich auflöst. Genau das ist die häufigste Quelle für ein trügerisches Muster: Der Editor zeigt keine Fehler, tsc kompiliert sauber, aber Node.js oder der Testrunner werfen beim Ausführen Cannot find module '@app/utils'.

Vite und Webpack benötigen eine eigene, separate Alias-Konfiguration, etwa resolve.alias in der vite.config.ts, die unabhängig von der tsconfig.json gepflegt wird. Für reine Node.js-Skripte ohne Bundler braucht ihr zusätzlich ein Paket wie tsconfig-paths oder tsc-alias, das die Aliase nach der Kompilierung in echte relative Pfade umschreibt. baseUrl beeinflusst zusätzlich, von welchem Wurzelverzeichnis aus nicht relative Importe ohne passenden paths-Eintrag gesucht werden, wird von node16/nodenext aber inzwischen weitgehend ignoriert.

6. moduleResolution zum tatsächlichen Build-Tool passend wählen

Die Faustregel ist einfach, wird aber trotzdem regelmäßig missachtet: moduleResolution soll immer das Werkzeug nachbilden, das eure Module zur Laufzeit tatsächlich auflöst, nicht irgendeine als modern geltende Standardeinstellung. Baut ihr mit Vite, Webpack oder esbuild, gehört "bundler" in eure tsconfig.json, weil genau diese Tools die Auflösung übernehmen und tsc nur noch für die Typprüfung zuständig ist. Führt ihr Code dagegen direkt mit tsc oder ts-node ohne zusätzlichen Bundler aus, muss die Einstellung exakt widerspiegeln, wie Node.js selbst auflöst, also node16 oder nodenext.

Bibliotheken, die als npm-Paket sowohl von CommonJS- als auch von ESM-Konsumenten importiert werden, sollten ebenfalls node16/nodenext verwenden, weil nur diese Strategie die für Dual Packages nötige exports-Feld-Validierung korrekt durchführt. Ein Vite-Alias in eurer vite.config.ts hat außerdem keine Wirkung auf die Typprüfung, solange die tsconfig.json nicht denselben Alias über paths kennt: Beide Konfigurationen müssen synchron gepflegt werden, sonst driften Editor-Erfahrung und tatsächliches Build-Verhalten auseinander.


// vite.config.ts - runtime alias resolution (this is what actually runs)
import { defineConfig } from 'vite';
import path from 'node:path';

export default defineConfig({
  resolve: {
    alias: {
      '@app': path.resolve(__dirname, 'src'),
    },
  },
});

// tsconfig.json must mirror the same alias for the type checker,
// otherwise the editor and tsc will report false "cannot find module" errors:
// {
//   "compilerOptions": {
//     "moduleResolution": "bundler",
//     "baseUrl": ".",
//     "paths": { "@app/*": ["src/*"] }
//   }
// }

7. "Cannot find module" Schritt für Schritt debuggen

Der erste Schritt bei jedem Cannot-find-module-Fehler ist, die genaue Fehlermeldung zu lesen, statt sie zu überfliegen. TypeScript unterscheidet zwischen mehreren verwandten, aber unterschiedlichen Fehlercodes: TS2307 (Cannot find module) deutet auf ein grundsätzliches Auflösungsproblem hin, TS2835 auf eine fehlende Dateiendung unter node16/nodenext, und TS7016 auf ein Paket ohne Typdefinitionen. Der Flag --traceResolution beim direkten tsc-Aufruf gibt für jeden Importpfad detailliert aus, welche Kandidatenpfade der Compiler geprüft hat und an welcher Stelle die Suche gescheitert ist.

Der zweite Schritt ist, module und moduleResolution auf Konsistenz zu prüfen: TypeScript verweigert seit Version 5 bestimmte inkonsistente Kombinationen direkt mit einem Konfigurationsfehler. Der dritte Schritt betrifft node_modules selbst: Ein vollständiger Neuinstall behebt überraschend viele Fälle, in denen eine veraltete package.json mit falschem exports-Feld gecacht wurde. Erst wenn all das ausgeschlossen ist, lohnt sich die Suche nach einem echten Konfigurationsfehler in eurer eigenen tsconfig.json.


$ npx tsc --noEmit

src/checkout.ts:3:29 - error TS2307: Cannot find module './utils' or its
corresponding type declarations.

3 import { formatPrice } from './utils';
                              ~~~~~~~~~~

# Enable verbose resolution tracing to see every candidate path tsc checked
$ npx tsc --noEmit --traceResolution | grep -A 2 "Module './utils'"

# Rule out a stale node_modules / exports-field cache
$ rm -rf node_modules package-lock.json && npm install

8. Monorepo und package.json "exports"-Fallstricke

In Monorepos mit npm-, pnpm- oder Yarn-Workspaces importiert ein Paket häufig ein anderes über den Paketnamen, etwa @internal/shared-ui. Sobald dieses interne Paket ein modernes exports-Feld definiert, blockiert node16/nodenext jeden Importpfad, der dort nicht explizit gelistet ist, selbst wenn die Zieldatei physisch existiert und über einen relativen Pfad problemlos erreichbar wäre. Ein häufiger Fehler: Ein Entwickler importiert @internal/shared-ui/dist/utils direkt, weil es früher funktionierte, doch nach Einführung eines exports-Feldes verschwindet dieser Pfad aus der öffentlichen API des Pakets.

Die zuverlässige Lösung ist, jeden bewusst öffentlichen Einstiegspunkt explizit im exports-Feld zu deklarieren, inklusive separater Einträge für types, import und require, damit sowohl node16/nodenext als auch ältere Konsumenten korrekt auflösen können. TypeScript-Project-References mit composite: true verschärfen das Problem zusätzlich, wenn die referenzierten Pakete unterschiedliche module-Einstellungen verwenden, weil die Ausgabeformate dann nicht mehr zueinander kompatibel sind.


{
  "name": "@internal/shared-ui",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    },
    "./styles.css": "./dist/styles.css"
  }
}

// Importing a path not listed above fails under node16/nodenext
// even though the file physically exists on disk:
// import { formatPrice } from '@internal/shared-ui/dist/utils';
// error TS2307: Cannot find module '@internal/shared-ui/dist/utils'
// or its corresponding type declarations.

9. Module-Resolution-Strategien im Vergleich

Die folgende Übersicht zeigt, welche moduleResolution-Einstellung zu welchem Build-Szenario passt und welche Wahl in der Praxis regelmäßig zu Cannot-find-module-Fehlern führt.

Szenario Falsche Wahl Empfohlene moduleResolution Warum
tsc-only CLI-Build (Node.js-Ausgabe) classic / node node16 / nodenext Bildet den echten Node.js-ESM-Resolver nach, inklusive exports-Feld und Endungspflicht
Vite-Projekt node16 bundler Vite erlaubt extensionlose Importe, node16 erzwingt fälschlich Dateiendungen
Webpack-Projekt classic bundler Webpack respektiert exports und braucht keine .js-Endung bei TS-Importen
Node.js-ESM-Bibliothek (npm-Paket) node node16 / nodenext Nur node16/nodenext validiert das exports-Feld korrekt für ESM-Konsumenten
Node.js-CommonJS-Bibliothek (npm-Paket) bundler node / node16 bundler ignoriert CommonJS-spezifische Regeln, die require() tatsächlich verwendet

In der Praxis liegt der größte Hebel nicht darin, die theoretisch modernste Einstellung zu wählen, sondern die Einstellung, die exakt zu dem Werkzeug passt, das eure Module tatsächlich zur Laufzeit auflöst. Ein Fehlgriff in dieser Tabelle erzeugt selten einen offensichtlichen Absturz, sondern eher stille Diskrepanzen zwischen Editor, Typprüfung und Produktionsverhalten, die erst bei ungewöhnlichen Importpfaden sichtbar werden.

Mironsoft

Build-Tooling, TypeScript-Konfiguration und Frontend-Infrastruktur für Magento- und Headless-Projekte

TypeScript-Build-Fehler zuverlässig lösen?

Wir analysieren eure tsconfig.json, gleichen moduleResolution mit eurem tatsächlichen Build-Tool ab und beheben Cannot-find-module-Fehler dauerhaft, egal ob ihr mit Vite, Webpack oder purem tsc arbeitet.

tsconfig-Audit

Analyse von moduleResolution, paths und exports-Feldern auf Inkonsistenzen

Build-Tool-Migration

Wechsel zwischen tsc, Vite und Webpack ohne Modulauflösungs-Chaos

Monorepo-Setup

Saubere exports-Felder und Project References für Workspaces

10. Zusammenfassung

Die moduleResolution-Strategien in TypeScript, node, node16/nodenext und bundler, lösen ein gemeinsames Grundproblem: Der Compiler muss nachbilden, wie Importpfade zur Laufzeit tatsächlich aufgelöst werden, sonst driften Typprüfung und Produktionsverhalten auseinander. Die klassische node-Strategie bildet den alten CommonJS-Algorithmus nach und erlaubt extensionlose Importe. node16/nodenext bildet den strikten ECMAScript-Module-Resolver von Node.js nach, inklusive Endungspflicht und exports-Feld-Validierung. bundler kombiniert die Bequemlichkeit extensionloser Importe mit modernem exports-Feld-Verständnis und ist die richtige Wahl für praktisch jedes Vite-, Webpack- oder esbuild-Projekt.

Der entscheidende Fehler ist selten technisches Unwissen, sondern eine Fehlkonfiguration, die nicht zum tatsächlichen Build-Tool passt: paths ohne passenden Bundler-Alias, node16 in einem Vite-Projekt, oder bundler in einer Bibliothek, die auch von reinen CommonJS-Konsumenten importiert wird. Wer moduleResolution konsequent an das tatsächliche Ausführungsumfeld koppelt und exports-Felder in eigenen Paketen explizit pflegt, eliminiert die meisten Cannot-find-module-Fehler dauerhaft, statt sie einzeln zu debuggen.

Module Resolution in TypeScript - Das Wichtigste auf einen Blick

node vs. node16

node bildet die alte CommonJS-Auflösung nach, node16/nodenext den echten ESM-Resolver mit Endungspflicht und exports-Validierung.

bundler-Strategie

Seit TypeScript 5.0 für Vite, Webpack und esbuild gedacht: extensionlose Importe plus modernes exports-Feld-Verständnis.

paths ist kein Runtime-Alias

tsconfig paths wirkt nur beim Typchecking. Vite, Webpack oder tsc-alias brauchen eine eigene, synchron gepflegte Alias-Konfiguration.

exports-Feld pflegen

Nur explizit gelistete Einstiegspunkte sind unter node16/nodenext importierbar, auch wenn die Datei physisch existiert.

11. FAQ: Module Resolution in TypeScript

1Was bedeutet moduleResolution in der tsconfig.json überhaupt?
Legt fest, nach welchem Algorithmus TypeScript einen Importpfad auflöst. Beeinflusst nur die Typprüfung, nicht die tatsächliche Auflösung zur Laufzeit durch Bundler oder Node.js.
2Was ist der Unterschied zwischen node und node16?
node bildet den alten CommonJS-Algorithmus nach und erlaubt extensionlose Importe. node16 bildet den strikten ESM-Resolver nach, mit Endungspflicht und exports-Feld-Prüfung.
3Warum verlangt node16 eine Dateiendung bei relativen Importen?
Weil node16 den echten Node.js-ESM-Resolver nachbildet, der Endungen zwingend erfordert. Erwartet wird die Endung der kompilierten Datei (.js), nicht der Quelldatei (.ts).
4Wann sollte ich moduleResolution: "bundler" verwenden?
Wenn Vite, Webpack oder esbuild die tatsächliche Auflösung übernehmen und tsc nur noch typprüft. Erlaubt extensionlose Importe und versteht moderne exports-Felder.
5Warum funktioniert mein paths-Alias in der IDE, aber nicht zur Laufzeit?
paths ist nur eine Kompilierzeit-Abkürzung ohne Laufzeit-Wirkung. Vite, Webpack oder tsc-alias brauchen eine eigene, synchron gepflegte Alias-Konfiguration.
6Was bedeutet der Fehler TS2307 Cannot find module genau?
Der Compiler findet keine passende Datei oder Typdeklaration für den Importpfad. Meist verursacht durch falsche moduleResolution, fehlende Endung unter node16 oder ein blockierendes exports-Feld.
7Warum blockiert das exports-Feld einen Import, obwohl die Datei existiert?
Unter node16/nodenext sind nur im exports-Feld gelistete Pfade importierbar. Die physische Existenz der Datei spielt keine Rolle, wenn sie nicht Teil der öffentlichen API ist.
8Kann ich moduleResolution einfach auf "bundler" für alle Projekte setzen?
Nein. Nur sinnvoll, wenn ein echter Bundler die Auflösung übernimmt. Für tsc-only-Builds oder CommonJS-Bibliotheken führt bundler zu falschen Annahmen und Laufzeitfehlern.
9Wie debugge ich Modulauflösungsfehler, die nur in CI auftreten?
Node.js-Version und Lockfile zwischen lokal und CI vergleichen, node_modules in CI neu installieren, tsc --traceResolution lokal mit identischer tsconfig.json ausführen.
10Was ist der Unterschied zwischen node16 und nodenext?
Funktional aktuell identisch. node16 ist an Node.js 16 fixiert, nodenext wächst automatisch mit künftigen Resolution-Änderungen mit. Für neue Projekte meist die zukunftssicherere Wahl.