Tips & Tricks
TypeScript Config Tricks
Use composite for caching and incremental builds, and split your tsconfigs so each environment only sees what it should.
Composite projects
The composite flag in tsconfig.json tells TypeScript to treat a project as a unit that can be built independently and cached. When enabled, tsc emits a .tsbuildinfofile that records what was checked and what the outputs were. On the next run, it skips any file whose inputs haven’t changed.
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true
}
}Incremental builds with project references
In a mono-repo with project references, this is where the real speed comes from. Instead of type-checking the entire repo, tsc --build walks the reference graph and only rebuilds projects whose source files changed. The first build is the same speed — every subsequent build is dramatically faster.
Faster IDE performance
The IDE benefits too. When the language server sees composite projects, it can load the cached declaration files instead of re-analysing source files across package boundaries. This means faster completions, faster go-to-definition, and lower memory usage.
Composite has no effect on Vite
If you’re using Vite, esbuild, or swc to compile your TypeScript, compositedoesn’t change your build output at all. These tools strip types and transpile file-by-file — they never run the type checker. The .tsbuildinfo cache, incremental rebuilds, and project references are all tsc features.
That doesn’t make composite pointless in a Vite project. You still run tsc for type checking (typically via tsc --noEmit or tsc --buildin CI), and the IDE’s language server still uses it. The build tool and the type checker are separate pipelines — composite speeds up the type checker, not the bundler.
The right tsconfig for the right job
Most projects have one tsconfig.jsonthat tries to cover everything: source code, tests, scripts, config files. This causes two problems. First, the compiler scope is too broad — it checks files it doesn’t need to, which slows things down. Second, every file gets the same type environment, which means your source code can accidentally use APIs that only exist in tests, or your Node scripts can reference browser globals that don’t exist at runtime.
Split your tsconfigs by target environment. Each config includes only the files it’s responsible for and only the types that exist in that environment.
tsconfig.json — the IDE config
The root tsconfig.json is what your IDE loads by default. Its job is to make editing pleasant: it should cover every file in the project so that go-to-definition, completions, and diagnostics work everywhere.
// tsconfig.json
{
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.test.json" }
],
"files": []
}This root config doesn’t compile anything itself — it delegates to the project references. The IDE’s language server resolves each file to the correct sub-config automatically, so a .test.ts file gets test types and a component file gets browser types.
tsconfig.app.json — the web build
This config targets your browser code. It includes only source files — no tests, no scripts, no config files. The lib and types fields expose only browser APIs.
// tsconfig.app.json
{
"compilerOptions": {
"composite": true,
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"types": []
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["src/**/*.test.*", "src/**/*.spec.*"]
}With "types": [], nothing from @types/*is auto-included. Your source code can’t accidentally use Node’s process, __dirname, or Buffer — if you try, the compiler catches it. The code you write is guaranteed to match the environment it runs in.
tsconfig.test.json — the test environment
Test files run in a different environment. Vitest injects globals like describe, it, expect, and vi. These should only be available inside test files — if they leak into source code, you’ve written code that compiles but will throw at runtime.
// tsconfig.test.json
{
"compilerOptions": {
"composite": true,
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"types": ["vitest/globals"]
},
"include": ["src/**/*.test.*", "src/**/*.spec.*"],
"references": [
{ "path": "./tsconfig.app.json" }
]
}The referencesfield lets test files import from the app source. But the app config can’t see the test files, and it can’t see vitest/globals. The boundary is enforced by the compiler.
tsconfig.node.json — Node scripts and config
If your project has Node-side code — build scripts, vite.config.ts, seed scripts, SSR entry points — those files need Node types, not DOM types.
// tsconfig.node.json
{
"compilerOptions": {
"composite": true,
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["node"]
},
"include": [
"vite.config.ts",
"scripts/**/*.ts"
]
}This config has no DOM types. A script that tries to use document or windowwill fail type checking — which is exactly what you want, because those APIs don’t exist where this code runs.
Why this matters
Splitting configs does two things. It makes the type checker faster by limiting the scope of each compilation — instead of checking every file against every type, each config checks only the files that belong to its environment. Combined with composite, this means tsc --buildcan skip entire projects when their inputs haven’t changed.
More importantly, it makes the type system honest. TypeScript is most valuable when it catches the mistakes that would crash at runtime. If your test globals are visible in source code, or your source code can reference Node APIs that don’t exist in the browser, the type checker is giving you false confidence. Each tsconfig should describe the environment its files actually run in — nothing more, nothing less.