TypeScript Tooling: Compiler Options, Runtimes, and Monorepo Setup
TypeScript Tooling: Compiler Options, Runtimes, and Monorepo Setup
TypeScript's value proposition is simple: catch bugs before they reach production. But the tooling around TypeScript has grown sprawling enough that configuring it well is a skill in itself. This guide covers the compiler options that actually matter, how to structure tsconfig.json for different project types, running TypeScript directly without a build step, and scaling TypeScript across a monorepo.
Compiler Options That Actually Matter
The tsconfig.json file has over 100 options. Most of them you can ignore. Here are the ones worth understanding.
Strictness
Turn on strict: true. It enables a bundle of checks (strictNullChecks, strictFunctionTypes, strictBindCallApply, noImplicitAny, etc.) and is the single most impactful setting. If you're starting a new project without strict: true, you're leaving TypeScript's best features on the table.
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true
}
}
noUncheckedIndexedAccess is not included in strict but is worth enabling. It makes array and object index access return T | undefined instead of T, catching a common class of runtime errors.
Module Resolution
This is where most confusion lives. The short version:
| Project Type | module |
moduleResolution |
|---|---|---|
| Modern Node.js (ESM) | NodeNext |
NodeNext |
| Bundled (Vite, webpack) | ESNext |
Bundler |
| Library (dual CJS/ESM) | NodeNext |
NodeNext |
| Bun | ESNext |
Bundler |
The Bundler module resolution was added in TypeScript 5.0 and reflects how bundlers actually resolve imports (allowing extensionless imports, package.json exports, etc.). If you use Vite, Next.js, or any bundler, use it.
Target and Lib
Set target to the lowest runtime you support. For server-side code targeting Node 20+, ES2022 or ESNext is fine. For browser code, let your bundler handle downleveling and set target: "ESNext".
lib should match your runtime. For Node.js: ["ES2023"]. For browsers: ["ES2023", "DOM", "DOM.Iterable"].
tsconfig.json for Different Project Types
Node.js API Server
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"declaration": true,
"sourceMap": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
Vite/React Frontend
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"jsx": "react-jsx",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}
Notice noEmit: true for bundled projects. TypeScript only does type checking; the bundler handles the actual compilation.
Shared Library
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
declarationMap is important for libraries -- it allows "Go to Definition" to jump to the original .ts source rather than the .d.ts file.
Running TypeScript Directly
You don't always need a build step. Several tools can run .ts files directly.
Bun
Bun has native TypeScript support with zero configuration. It strips types at startup and runs the JavaScript. This is the fastest option.
bun run src/index.ts
bun test
Bun does not type-check at runtime. It only strips types. You still need tsc --noEmit for actual type checking.
tsx
tsx is a Node.js-based TypeScript runner that uses esbuild under the hood. It's the best option when you need Node.js compatibility but want to skip the build step.
npx tsx src/index.ts
npx tsx watch src/index.ts # with file watching
ts-node
ts-node was the original TypeScript runner but is slower than tsx and has more configuration overhead. Unless you have a specific reason (like needing transformers or path aliases), prefer tsx or Bun.
Node.js Native (--experimental-strip-types)
Node.js 22+ added --experimental-strip-types, which strips TypeScript types natively without a third-party loader. In Node 23.6+, it works without the flag for .ts files. This is worth watching but still has rough edges around features like enums and decorators.
node --experimental-strip-types src/index.ts
Type Checking in CI
Your CI pipeline should run type checking as a separate step from your build. This catches type errors even when your bundler would happily ignore them.
# GitHub Actions example
- name: Type check
run: bunx tsc --noEmit
# Or for monorepos with project references
- name: Type check
run: bunx tsc --build --noEmit
Run type checking early in your CI pipeline, ideally in parallel with linting and tests. It's fast enough (usually under 30 seconds for medium projects) that there's no reason to skip it.
A common mistake: relying on your editor's TypeScript integration as your only type checking. Editors can have stale caches, different TypeScript versions, or misconfigured settings. CI is the source of truth.
Monorepo TypeScript with Project References
When your monorepo has multiple packages that depend on each other, TypeScript project references let you type-check them efficiently.
Structure
packages/
shared/
tsconfig.json
src/
api/
tsconfig.json # references shared
src/
web/
tsconfig.json # references shared
src/
tsconfig.json # root, references all packages
Root tsconfig.json
{
"files": [],
"references": [
{ "path": "packages/shared" },
{ "path": "packages/api" },
{ "path": "packages/web" }
]
}
Package tsconfig.json (api)
{
"compilerOptions": {
"composite": true,
"outDir": "dist",
"rootDir": "src"
},
"references": [
{ "path": "../shared" }
],
"include": ["src"]
}
The composite: true flag is required for any project that's referenced by another. It forces declaration: true and ensures TypeScript can do incremental builds.
Build the entire project graph with:
tsc --build
This compiles packages in dependency order and only rebuilds what changed. For large monorepos, this can cut type-checking time significantly compared to checking each package independently.
Trade-offs
Project references add configuration overhead. For small monorepos (2-3 packages), you might prefer a simpler setup where each package independently type-checks and relies on paths aliases. The break-even point is around 4-5 packages or when incremental build speed becomes noticeable.
Recommendations
- Start every project with
strict: trueandnoUncheckedIndexedAccess: true - Use
moduleResolution: "Bundler"for bundled projects,NodeNextfor everything else - Run TypeScript directly with Bun (fastest) or tsx (Node.js compatible)
- Always run
tsc --noEmitin CI, separate from your build - Adopt project references when your monorepo has 4+ packages with inter-dependencies
- Keep
skipLibCheck: trueunless you're authoring a library that needs to validate its own type declarations