Die tsconfig.json ist das Steuerzentrum jedes TypeScript-Projekts: Sie entscheidet, welche Sprachversion am Ende emittiert wird, wie Module aufgelöst werden, wie streng der Typchecker arbeitet und welche Dateien überhaupt zum Projekt gehören. Wer die zentralen Optionen versteht, spart sich endlose Stunden Debugging an scheinbar zufälligen Modul- oder Typfehlern. Dieser Artikel führt dich durch die wichtigsten Felder, zeigt eine pragmatische Minimal-Konfiguration und eine moderne strict-Variante für ernsthafte Projekte — und erklärt die Stolperfallen rund um moduleResolution, paths und das Zusammenspiel mit Bundlern wie Vite oder Next.js. Stand der Beispiele: TypeScript 5.8 (Mai 2026).
Was die tsconfig.json macht
Die tsconfig.json markiert die Wurzel eines TypeScript-Projekts. Sie legt fest, welche Dateien zum Projekt gehören und mit welchen Optionen der Compiler tsc sie verarbeitet. Ohne diese Datei kennt TypeScript weder dein Ziel-JS noch deine Modulauflösung — tsc fällt dann auf hartcodierte Defaults zurück, die selten zu deinem Setup passen.
Auffinden: Wenn du tsc ohne Argumente startest, sucht der Compiler im aktuellen Verzeichnis nach tsconfig.json und arbeitet sich anschliessend die Verzeichnis-Hierarchie nach oben durch, bis er fündig wird. Du kannst auch explizit eine Konfiguration angeben:
tsc --project ./tsconfig.build.json
# oder kurz:
tsc -p ./tsconfig.build.jsonWichtig: Sobald du tsc mit konkreten Dateinamen aufrufst (tsc src/index.ts), wird tsconfig.json ignoriert. Das ist ein häufiger Stolperstein in CI-Skripten — bau immer per tsc -p.
Generator tsc --init: Eine sinnvolle Start-Konfiguration bekommst du mit:
npx tsc --initDie generierte Datei enthält viele auskommentierte Optionen mit Kurzbeschreibung — sehr nützlich zum Stöbern, aber für reale Projekte zu lang. In der Praxis startet man besser mit einer schlanken Datei oder einer fertigen Base-Konfiguration über extends (siehe Specials am Ende).
Minimalkonfiguration
Die kleinste sinnvolle tsconfig.json für ein modernes Node-Projekt sieht so aus:
{
"compilerOptions": {
"target": "es2022",
"module": "nodenext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["src/**/*"]
}Was hier passiert:
target: es2022— Output ist modernes JavaScript, Node 18+ versteht das ohne Probleme.module: nodenext— Echtes ESM-/CommonJS-Handling nach Node-Spezifikation; setzt automatischmoduleResolution: nodenextmit.strict: true— Alle strikten Checks aktivieren (siehe Abschnitt 5).esModuleInterop: true— Interop zwischen ESM-Imports und CommonJS-Modulen; quasi Pflicht.skipLibCheck: true—.d.ts-Dateien ausnode_moduleswerden nicht typgeprüft, was Build-Zeit massiv spart.outDir: dist— Kompiliertes JavaScript landet unterdist/.
Mehr brauchst du für den Anfang nicht. Alles Weitere ist Optimierung.
target und lib
target bestimmt, welche JavaScript-Version tsc als Output erzeugt — wichtig, weil neueres Syntax-Sugar (optional chaining, nullish coalescing, top-level await, decorators) je nach Target entweder direkt emittiert oder zu älterem JS umgeschrieben wird.
lib bestimmt, welche eingebauten Typen (DOM-API, ES-API) dir während der Typprüfung zur Verfügung stehen. Wenn du lib nicht setzt, leitet TypeScript es aus target ab.
| Target | Verfügbare Sprachfeatures (Auswahl) | Typischer Einsatz |
|---|---|---|
es5 | Klassisches ES5, kein let/const-Down-Level. | Legacy-Browser, IE11. |
es2015 | Klassen, let/const, Promises, Module. | Sehr alte Bundler-Targets. |
es2017 | async/await nativ. | Node 8+, alte Edge. |
es2020 | Optional chaining, nullish coalescing, BigInt. | Node 14+, alle modernen Browser. |
es2022 | Class fields, at(), top-level await (in Modulen). | Node 18+ — guter Default heute. |
esnext | Alles, was TypeScript aktuell kennt. | Nur sinnvoll mit Bundler, der das wieder runterkompiliert. |
Praxisregel: Wenn ein Bundler (Vite, esbuild, Rollup, Next.js) am Werk ist, ist target: esnext oder es2022 fast immer die richtige Wahl — der Bundler kümmert sich um Browser-Kompatibilität. Wenn tsc selbst der finale Compiler ist (klassische Node-Library), wähle das niedrigste target, das deine Ziel-Node-Version garantiert versteht.
lib getrennt setzen: In einer Browser-App brauchst du explizit dom:
{
"compilerOptions": {
"target": "es2022",
"lib": ["es2022", "dom", "dom.iterable"]
}
}In einer reinen Node-Library lässt du dom weg — dann meckert TypeScript zu Recht, wenn jemand versehentlich document.querySelector schreibt.
module und moduleResolution
Diese beiden Optionen sind die häufigste Fehlerquelle in tsconfigs. Sie bestimmen:
module— wie der Output aussieht (CommonJSrequireoder ESMimport).moduleResolution— wie TypeScript Imports auflöst (welche Dateien finde ich hinterimport x from 'y').
| Wert | Output-Form | Empfohlen für |
|---|---|---|
commonjs | require/module.exports | Klassische Node-Projekte ohne ESM. |
esnext | Reines import/export | Projekte mit Bundler. |
node16 / nodenext | Mix aus CJS und ESM, abhängig von package.json type | Moderne Node-Libraries mit Dual-Publishing. |
preserve (seit 5.4) | Lässt Import-Syntax unverändert | Wenn ein Bundler den Rest macht. |
moduleResolution-Werte:
node— alter Default, kennt keine.mjs/.cjs-Unterscheidung. Nicht mehr verwenden.node16/nodenext— Node-konformes Verhalten, inkl. expliziter Datei-Extensions in Imports.bundler— seit TypeScript 5.0, der pragmatische Modus für Vite, Webpack, Next.js, esbuild. Erlaubt extensionslose Imports und Auflösung überpackage.json-exports, ohne dasstscselbst Code erzeugt.classic— Legacy, ignorieren.
Faustregel:
- Bundler im Spiel (Vite, Next, Remix, Webpack)? →
module: esnext+moduleResolution: bundler. - Reine Node-Library, die per
tscgebaut wird? →module: nodenext(moduleResolution: nodenextwird automatisch mitgesetzt). - Etwas dazwischen? → meistens auch
nodenext.
Die strict-Family
strict: true ist der wichtigste Schalter in der ganzen Datei. Er aktiviert auf einen Schlag alle strikten Typchecks:
| Flag | Wirkung |
|---|---|
noImplicitAny | Variablen ohne erschliessbaren Typ werden zum Fehler. |
strictNullChecks | null und undefined sind eigene Typen, keine impliziten Mitglieder anderer Typen. |
strictFunctionTypes | Strenge bivariante vs. kontravariante Parameter-Prüfung. |
strictBindCallApply | .bind/.call/.apply werden typgeprüft. |
strictPropertyInitialization | Klassen-Properties müssen im Konstruktor zugewiesen oder als optional markiert werden. |
noImplicitThis | this mit implizitem any wird zum Fehler. |
alwaysStrict | Emittiert "use strict" in jeder Datei. |
useUnknownInCatchVariables | catch (e) typisiert e als unknown statt any. |
Empfehlung ohne Wenn und Aber: In jedem neuen Projekt strict: true setzen. Die geringe Mehrarbeit beim Schreiben wird durch wegfallende Laufzeitfehler um Grössenordnungen aufgewogen — gerade strictNullChecks ist der Hauptgrund, warum sich TypeScript-Code von JavaScript-Code spürbar sicherer anfühlt.
Wer noch strenger fahren will, ergänzt:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"exactOptionalPropertyTypes": true
}
}noUncheckedIndexedAccess ist besonders wertvoll: Es zwingt dich, bei Array- und Objekt-Index-Zugriffen undefined zu behandeln.
rootDir, outDir, include, exclude
Diese vier Optionen bestimmen den Datei-Scope des Projekts.
rootDir— der Quellordner. Wenn nicht gesetzt, leitet TypeScript ihn aus deninclude-Patterns ab. Wichtig, weiloutDirdie Verzeichnis-Struktur relativ zurootDirspiegelt.outDir— wohin der kompilierte JavaScript-Output geschrieben wird.include— Glob-Patterns, welche Dateien zum Projekt gehören.exclude— Glob-Patterns, was ausgeschlossen wird. Default schliesstnode_modules,bower_components,jspm_packagesundoutDiraus.
{
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*"],
"exclude": ["**/*.test.ts", "**/*.spec.ts"]
}Stolperfalle rootDir: Wenn du Imports ausserhalb von rootDir machst (z. B. import { x } from '../shared/utils', während rootDir auf src zeigt), bricht tsc mit einer kryptischen Meldung ab. Lösung: entweder rootDir weiter oben ansetzen oder die Quelldatei nach src ziehen.
include vs. files: Statt Glob-Patterns kannst du auch files: ["src/index.ts", "src/server.ts"] schreiben — sinnvoll, wenn du wirklich nur einzelne Einstiegspunkte hast (selten).
paths und baseUrl
Aliase wie @/components/Button statt ../../../components/Button machen Imports lesbar. Konfiguriert wird das über paths plus optional baseUrl.
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@utils": ["src/utils/index.ts"]
}
}
}Seit TypeScript 5.0 ist baseUrl optional, wenn du paths mit relativen Patterns angibst — der Compiler löst dann ab dem Verzeichnis der tsconfig.json auf.
Die grosse Falle: paths ist nur eine Information für den Typchecker. Zur Laufzeit weiss weder Node noch der Browser etwas davon. Wenn dein Bundler (Vite, Webpack, esbuild) den Alias nicht parallel auflöst, knallt es beim Start mit „module not found".
Lösung — Alias muss zweimal eingetragen werden:
import { defineConfig } from "vite";
import path from "node:path";
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
});Tools wie vite-tsconfig-paths oder tsconfig-paths für Node lesen die Konfiguration direkt aus tsconfig.json aus — komfortabler, aber eine zusätzliche Abhängigkeit.
tsBuildInfoFile, incremental, noEmit
Drei Optionen, die du je nach Workflow brauchst.
incremental: true— TypeScript speichert nach jedem Build einen kleinen Snapshot (.tsbuildinfo), aus dem der nächste Build nur noch die Diffs berechnet. Beschleunigt grosse Projekte enorm.tsBuildInfoFile— wohin diese Info geschrieben wird. Standard ist neben dem Output. In Monorepos oft sinnvoll, sie an einen zentralen Ort zu legen.noEmit: true— TypeScript prüft Typen, schreibt aber keine Dateien. Das ist der Normalfall, wenn ein Bundler den eigentlichen Build macht:tsc --noEmitläuft dann als reiner Linter in der CI.
{
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": "./.tsbuildinfo",
"noEmit": true
}
}In package.json taucht das dann typischerweise so auf:
{
"scripts": {
"build": "vite build",
"typecheck": "tsc --noEmit"
}
}Beispiel: moderne strict-tsconfig für Node-Library
Eine produktionsreife Konfiguration für eine klassische, per tsc gebaute Node-Library:
{
"compilerOptions": {
"target": "es2022",
"module": "nodenext",
"lib": ["es2022"],
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"incremental": true
},
"include": ["src/**/*"],
"exclude": ["**/*.test.ts", "dist"]
}Highlights:
declaration/declarationMap—.d.ts-Dateien plus Mappings, damit Konsumenten der Library direkt in deine Quellen springen können.verbatimModuleSyntax— erzwingt explizitesimport type/export type, klare Trennung von Werten und Typen.isolatedModules— stellt sicher, dass jede Datei auch einzeln kompiliert werden kann (Pflicht für Babel/SWC-Konsumenten).forceConsistentCasingInFileNames— verhindert die klassische macOS-vs-Linux-Falle, bei derUser.tsunduser.tsauf dem Mac dasselbe sind, in der CI aber nicht.
Beispiel: tsconfig für Vite/Next.js/Bundler
Wenn ein Bundler den eigentlichen Build macht und tsc nur Typchecker ist:
{
"compilerOptions": {
"target": "es2022",
"module": "esnext",
"moduleResolution": "bundler",
"lib": ["es2022", "dom", "dom.iterable"],
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"incremental": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src", "vite.config.ts"]
}Highlights:
moduleResolution: bundler+module: esnext— der Bundler löst auf,tscschreibt nichts.noEmit: true— TypeScript ist hier nur Typchecker.allowImportingTsExtensions— erlaubtimport x from './foo.ts', was Vite und moderne Bundler problemlos verarbeiten.jsx: react-jsx— moderner JSX-Transform ohneimport React from 'react'-Pflicht.
Besonderheiten
extends für Konfig-Vererbung nutzen.
Mit extends erbst du eine Basis-Konfiguration aus einem npm-Paket oder einer lokalen Datei. Die offiziellen Bases liegen unter @tsconfig/* — etwa @tsconfig/strictest, @tsconfig/node20 oder @tsconfig/vite-react. Damit reduziert sich deine projekt-spezifische Datei auf wenige Zeilen.
references für Monorepo-Projekt-Strukturen.
Mit references verlinkst du mehrere TypeScript-Projekte miteinander, sodass tsc --build sie in der richtigen Reihenfolge inkrementell kompiliert. Pflicht-Begleiter ist composite: true in jedem referenzierten Sub-Projekt — ohne das verweigert tsc die Project-References.
verbatimModuleSyntax löst zwei Vorgänger-Flags ab.
Seit TypeScript 5.0 ersetzt verbatimModuleSyntax die alten Flags importsNotUsedAsValues und preserveValueImports. Die Regel ist simpel: Jeder Import, der nur als Typ verwendet wird, muss mit import type deklariert sein. Klare Trennung, deterministischer Output.
isolatedModules erzwingt Bundler-Kompatibilität.
Tools wie Babel, SWC oder esbuild kompilieren jede Datei einzeln und sehen den restlichen Projektkontext nicht. isolatedModules: true stellt sicher, dass du keine Syntax verwendest, die für solche Single-File-Transpiler nicht eindeutig auflösbar ist — z. B. const-enums ohne Inlining.
skipLibCheck als pragmatischer Default.
skipLibCheck: true überspringt die Typprüfung aller .d.ts-Dateien aus node_modules. In der Praxis ist das fast immer richtig — fremde Type-Definitions sind nicht dein Problem, und der Build wird spürbar schneller. Der einzige Grund dagegen: du schreibst selbst Type-Definitions und willst sie mitgeprüft sehen.
allowImportingTsExtensions für expliziten .ts-Import.
Seit TypeScript 5.0 erlaubt allowImportingTsExtensions: true Imports wie import x from './foo.ts'. Voraussetzung: entweder noEmit: true oder emitDeclarationOnly: true. Praktisch in Vite/Deno/Bun-Umgebungen, die ohnehin .ts-Endungen erwarten.
module: nodenext setzt moduleResolution automatisch.
Sobald du module: nodenext (oder node16) wählst, wird moduleResolution automatisch auf denselben Wert gesetzt — beide gehören untrennbar zusammen. Manuelles Mischen wie module: nodenext + moduleResolution: bundler führt zu Fehlern.
Project-References mit composite: true für Monorepo-Builds.
In einem Monorepo mit mehreren Paketen aktivierst du in jedem Sub-Paket composite: true. Damit erzeugt tsc die nötigen Build-Infos und Declaration-Files, sodass das übergeordnete Projekt diese Pakete per references einbinden und gemeinsam mit tsc --build inkrementell kompilieren kann.