T
he defaulttsconfig.json that tsc --init generates has 96 commented-out options. Most of them don't matter for a production API or web app. Some of them matter a lot. A handful will quietly cause bugs if you ignore them.
This isn't a rehash of the TypeScript docs. It's the config we use on production projects, with the reasoning for each decision including what we turned off and why.
Start strict, not permissive
The first thing most teams get wrong: starting with a permissive config and trying to tighten it later.
Every option you defer adds debt. A codebase with 200 files and strict: false will produce hundreds of errors when you finally enable it and nobody wants to fix them under pressure. The errors are real. Disabling strict mode just hid them.
Start here:
Untitled1{2 "compilerOptions": {3 "strict": true4 }5}
strict: true enables a bundle of checks: strictNullChecks, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, noImplicitAny, and noImplicitThis. All of them are on. That's the point.
If you're adding TypeScript to an existing JavaScript project, strict: true on day one is painful. Use // @ts-check file-by-file, or enable strict in a new tsconfig.strict.json that overrides the base for new code only. But for a new TypeScript project, there's no good reason to start loose.
The output options that actually matter
Untitled1{2 "compilerOptions": {3 "target": "ES2022",4 "module": "NodeNext",5 "moduleResolution": "NodeNext",6 "outDir": "./dist",7 "rootDir": "./src",8 "declaration": true,9 "declarationMap": true,10 "sourceMap": true11 }12}
target: "ES2022" modern Node.js (18+) supports ES2022 natively. There's no reason to compile down to ES5 for a server-side project. If you're targeting browsers, check your browser support matrix. For APIs, ES2022 is fine.
module: "NodeNext" and moduleResolution: "NodeNext" this pair is essential for modern Node.js. NodeNext understands both ESM and CommonJS, and it correctly resolves .js extensions in imports (which ESM requires). Using CommonJS here is the path to confusing dual-format headaches.
declaration: true and declarationMap: true generate .d.ts files. If you're building a library or a shared internal package, you need these. For a standalone API that isn't consumed externally, you can skip declaration. But declarationMap makes "Go to Definition" in VSCode actually jump to source rather than the compiled output worth it on any project.
sourceMap: true maps compiled output back to source TypeScript. Your error traces in production will reference actual line numbers in your .ts files. Without this, stack traces point to compiled JavaScript and you're debugging blindfolded.
The checks that catch real bugs
Beyond strict, these are the options that prevent specific classes of production errors:
Untitled1{2 "compilerOptions": {3 "noUnusedLocals": true,4 "noUnusedParameters": true,5 "noImplicitReturns": true,6 "noFallthroughCasesInSwitch": true,7 "exactOptionalPropertyTypes": true,8 "useUnknownInCatchVariables": true9 }10}
noUnusedLocals and noUnusedParameters dead code is a signal. A variable declared and never used usually means something didn't get wired up. A parameter that's ignored might mean the function signature changed and nobody updated the callers. Both surface real issues during development instead of code review.
noImplicitReturns every code path in a function that returns a value must return something. Without this, a function that returns a string in one branch and nothing in another branch is silently typed as string | undefined. Turn this on and TypeScript will tell you at compile time.
noFallthroughCasesInSwitch switch case fallthrough is almost always a bug. This makes it a compile error unless you explicitly mark intentional fallthrough.
exactOptionalPropertyTypes this one is underused and underappreciated. Without it, TypeScript treats { name?: string } as { name: string | undefined }, which means you can set name to undefined explicitly even though ? means the property can be absent. With exactOptionalPropertyTypes, absent and undefined are distinct. If you're using Zod or any schema validation library, this catches a real class of subtle mismatches.
useUnknownInCatchVariables since TypeScript 4.4, caught errors in catch blocks default to unknown rather than any. This is correct: you genuinely don't know what was thrown. Enable it explicitly so your catch blocks are honest about what they're handling.
The part most people get wrong
Path aliases. Teams add them to tsconfig.json and then wonder why their built code crashes at runtime.
Untitled1{2 "compilerOptions": {3 "paths": {4 "@shared/*": ["./src/shared/*"]5 }6 }7}
TypeScript resolves these aliases at type-check time. The TypeScript compiler does not transform them in the emitted JavaScript. Your compiled dist/ files still contain import { AppError } from '@shared/errors/AppError' which Node.js has no idea what to do with.
The fix is a separate tool. With tsc output: use tsc-alias. With esbuild or Bun: configure the aliases in the bundler config too. The tsconfig.json paths are a contract between you and the type checker, not the runtime.
This trips up teams who configure everything in tsconfig.json, ship to production, and get a module resolution error they can't reproduce locally because their local setup has the aliases working through a dev server.
Real-world example: the config we ship
Here's the full tsconfig.json from a TypeScript API project running in production on AWS in the Jakarta region:
Untitled1{2 "compilerOptions": {3 "target": "ES2022",4 "module": "NodeNext",5 "moduleResolution": "NodeNext",6 "lib": ["ES2022"],7 "outDir": "./dist",8 "rootDir": "./src",9 "strict": true,10 "noUnusedLocals": true,11 "noUnusedParameters": true,12 "noImplicitReturns": true,13 "noFallthroughCasesInSwitch": true,14 "exactOptionalPropertyTypes": true,15 "useUnknownInCatchVariables": true,16 "declaration": true,17 "declarationMap": true,18 "sourceMap": true,19 "paths": {20 "@shared/*": ["./src/shared/*"],21 "@lib/*": ["./src/lib/*"],22 "@features/*": ["./src/features/*"]23 }24 },25 "include": ["src"],26 "exclude": ["node_modules", "dist"]27}
What we don't have: esModuleInterop, allowSyntheticDefaultImports, allowJs, skipLibCheck.
skipLibCheck: true is the most abused option in the TypeScript ecosystem. It suppresses type errors in node_modules. Teams enable it to silence errors from poorly-typed third-party packages. The right fix is to use @types/* packages, or to write local declaration files for packages without types. skipLibCheck is a painkiller that masks real compatibility issues.
allowJs: true is legitimate during incremental migration from JavaScript. It doesn't belong in a fully TypeScript project.
FAQ
Q: Should I have separate tsconfig.json files for development and production?
A: Yes, for anything non-trivial. A base tsconfig.json holds shared compiler options. A tsconfig.build.json extends it and excludes test files from the compiled output ("exclude": ["**/*.test.ts", "**/*.spec.ts"]). Your build command runs tsc -p tsconfig.build.json. Development and type-checking use the base.
Q: What about isolatedModules: true?
A: Enable it. It makes TypeScript behave consistently with tools like esbuild and Babel that process files individually rather than as a full program. It catches a specific class of errors like export type that gets treated as a value in single-file compilers that would only appear at runtime with those tools.
Q: We're on an older Node.js version. Does this config still work?
A: Adjust target and lib to match your runtime. Node.js 16 supports ES2021. Node.js 18 supports ES2022. The module: "NodeNext" setting requires Node.js 16+ for ESM support. If you're on Node.js 14, use module: "CommonJS" and accept the tradeoffs.
Q: How do we handle tsconfig.json in a monorepo?
A: One base tsconfig.json at the repo root defines shared compiler options. Each package extends it: "extends": "../../tsconfig.json". Package-level configs override only what's specific to that package outDir, rootDir, paths. Don't duplicate compiler options across every package config.
Q: Is there a way to check if our tsconfig.json is doing what we think?
A: Run tsc --showConfig to see the fully resolved compiler options after all extends are merged. Run tsc --noEmit to type-check without producing output useful in CI as a standalone type safety gate before your build step.
The tsconfig isn't exciting. But a bad one causes errors you chase for hours before realising the compiler was lying to you. Set it strict on day one, understand the output options, and handle path aliases in the build tool rather than assuming TypeScript does it all.
For the linting side of this formatting rules, Biome configuration, keeping the toolchain coherent see [→ Read: Biome: How to Set Up Go-Level Linting and Formatting in Your TypeScript Project]. And for how this config fits into the full project setup, the starting point is [→ Read: The TypeScript Project Setup That Stops Arguments and Ships Faster].