A good tsconfig.json can make TypeScript feel predictable instead of fragile. This guide focuses on the TypeScript config options that matter most for modern projects, explains why they matter, and gives you a reusable starting point you can adapt for apps, libraries, Node services, and monorepos over time.
Overview
If you search for a tsconfig guide, you will usually find one of two extremes: a massive list of compiler flags with little context, or a tiny example that only works for one stack. Most teams need something in the middle: a practical reference for choosing TypeScript compiler options without having to relearn the whole compiler every time a project changes.
The main job of tsconfig.json is to define how TypeScript understands your codebase. It controls four important areas:
- Type checking strictness: how aggressively the compiler catches mistakes
- JavaScript output: what code gets emitted and what runtime environment it targets
- Module behavior: how imports and exports are resolved
- Project boundaries: which files belong to the project and how larger codebases are organized
For modern projects, the most important rule is simple: keep your config as strict as your codebase can tolerate, and as minimal as your tooling allows. Many frameworks already set sensible defaults. Your job is not to enable every compiler option. Your job is to choose options that protect the code you actually ship.
As a baseline, most modern teams want a config that does the following:
- catches null and type safety bugs early
- works cleanly with ES modules and current package tooling
- avoids surprising emit behavior
- supports IDE navigation and refactoring
- stays readable enough to maintain
If you are already working across frontend build tools, API clients, and config-heavy workflows, it helps to treat TypeScript config the same way you treat JSON schemas or structured outputs: small, explicit, and easy to compare. If you need a similar workflow for config review, a side-by-side diff process like the one described in JSON Diff Tools Compared is useful whenever tsconfig files drift between projects.
Template structure
Here is a reusable starting point for a modern application-level tsconfig. It is not the only valid setup, but it gives you a strong base for current web and Node-oriented projects.
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"skipLibCheck": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"allowJs": false,
"checkJs": false,
"resolveJsonModule": true,
"esModuleInterop": false,
"forceConsistentCasingInFileNames": true,
"useDefineForClassFields": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}Now break that into parts so each setting earns its place.
Language target and output
target: Sets the JavaScript language level for emitted code. For modern projects, a recent target such asES2022is a reasonable starting point when your runtime or bundler can handle it.module: Controls module output.ESNextis commonly a good fit for bundler-based apps because the bundler handles final transformation.noEmit: Important when another tool handles builds, such as a framework, bundler, or test runner. If TypeScript is only checking types, this avoids confusion about generated output.
If TypeScript itself is producing distributable JavaScript, noEmit may need to be false. That is more common for packages, CLIs, and some server builds.
Module behavior and resolution
moduleResolution: One of the most important modern settings.Bundlerusually fits projects built with modern frontend tooling. For some Node-focused projects,NodeNextmay be a better match.verbatimModuleSyntax: Keeps import and export syntax closer to what you wrote. This reduces surprises and makes module behavior easier to reason about.resolveJsonModule: Useful if your project imports JSON files directly.esModuleInterop: Often misunderstood. It changes compatibility behavior with CommonJS-style imports. Do not enable it by reflex; use it when your tooling or dependency patterns actually require it.
A lot of frustrating TypeScript compiler options problems are really module system problems. If imports compile but fail at runtime, or work in tests but not in production, the issue is often a mismatch between module, moduleResolution, package metadata, and the runtime.
Strictness and bug prevention
strict: The single most valuable setting. It enables a group of type-safety checks and should be on for nearly all new projects.noUncheckedIndexedAccess: Addsundefinedwhen accessing object keys or array indexes that may not exist. This catches many real bugs in config processing, API data handling, and dynamic object lookups.exactOptionalPropertyTypes: Makes optional properties behave more precisely. This is especially helpful when modeling API payloads, partial updates, and objects with meaningful absence versus explicitundefined.forceConsistentCasingInFileNames: Prevents casing issues that work on one filesystem and break on another.
These are some of the best tsconfig settings because they protect real production code paths instead of only satisfying style preferences.
Build-tool compatibility
isolatedModules: Important when files may be transpiled independently by tools outside the TypeScript compiler. This is common in frontend build systems.useDefineForClassFields: Keeps class field behavior aligned with current JavaScript semantics. In modern codebases, it is often safer to keep this enabled.skipLibCheck: Often a practical performance tradeoff. It skips type checking of declaration files in dependencies, which reduces noise and compile cost. For many application projects, this is a sensible default.
skipLibCheck is one of those TypeScript config options where purity and practicality collide. In theory, checking everything sounds safer. In practice, application teams often prefer faster builds and fewer dependency-originated type issues. For libraries, the decision may deserve more scrutiny.
Framework-specific settings
Some projects need extra options:
jsxfor React or JSX-based stacksbaseUrlandpathsfor import aliasestypesto explicitly include ambient type packageslibto define available built-in APIs such as DOM or newer ECMAScript features
These options are useful, but they should be added with purpose. Alias-heavy tsconfig files can become harder to move between tools. Keep path mappings aligned with your bundler, test runner, and editor, or you will end up debugging resolution mismatches in multiple places.
How to customize
The right tsconfig for modern projects depends less on TypeScript itself and more on how your code is built and executed. Start with your runtime and build chain, then choose compiler options that match.
For frontend apps using a bundler
For browser-based apps built with a modern bundler or framework, a good strategy is:
- use
module: "ESNext" - use
moduleResolution: "Bundler" - keep
noEmit: true - enable strict checks
- use the framework's recommended JSX mode if needed
In this setup, TypeScript is mainly a checker and editor assistant. The bundler handles code transformation. The fewer overlapping responsibilities you create, the fewer surprises you get.
For Node services and scripts
Backend projects need more care around module resolution because runtime behavior matters directly. Questions to answer first:
- Is the project using ES modules, CommonJS, or a mix?
- Will Node execute the output directly?
- Will another tool transpile before runtime?
For a Node-first project where runtime module behavior must closely match emitted files, consider settings that align with Node's own expectations, often around NodeNext semantics. If you are writing automation scripts in TypeScript, the simplest setup is usually the best one: clear output, predictable modules, and no extra aliases unless they save real time.
For published libraries
Libraries are different from applications because other people consume their output and type declarations. That means your config has to support packaging, declaration generation, and compatibility. Common additions include:
declaration: trueemitDeclarationOnly: truein some build workflowsoutDirfor generated artifactsrootDirfor predictable file structure
Library configs should be explicit about emit behavior. If an app tsconfig is about developer convenience, a library tsconfig is about contract clarity.
For monorepos
In larger codebases, a single tsconfig often becomes hard to maintain. A better pattern is to use a base config plus package-level extension.
// tsconfig.base.json
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
}
}// packages/web/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"noEmit": true,
"target": "ES2022"
},
"include": ["src"]
}This approach keeps your TypeScript compiler options consistent while still allowing package-level differences. It also makes review easier when one package changes behavior without affecting the whole repo.
When to avoid adding more settings
Not every compiler flag improves a project. Avoid adding options just because they appear in a blog post or generated config. A setting should answer one of these questions:
- Does this catch a class of bugs we actually see?
- Does this align TypeScript with how our runtime works?
- Does this make builds or editor feedback more reliable?
- Does this support packaging or distribution requirements?
If the answer is no, leave it out. A shorter tsconfig is easier to trust.
Examples
Use these examples as practical starting points, then refine them to match your toolchain.
Example 1: Modern frontend application
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"useDefineForClassFields": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}Best for apps where the framework or bundler owns the build output.
Example 2: Node service with emitted output
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}Best for backend code where TypeScript emits files used by the runtime directly.
Example 3: Shared base config
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"useDefineForClassFields": true
}
}Best for teams that want one place to define non-negotiable safety defaults.
As you compare examples, notice that the biggest differences are usually around module, moduleResolution, and emit behavior. Those are the settings most tied to real runtime behavior, which is why they deserve the most attention.
If your work also involves translating API examples into TypeScript clients, it is worth keeping your module and fetch-related tooling consistent across projects. A workflow similar to cURL to Fetch Converter Guide can help when copying request patterns from terminal examples into strongly typed app code.
When to update
A tsconfig file should not be treated as permanent. It is a living contract between your code, your tools, and your runtime. Revisit it when one of these conditions changes:
- Your framework changes its defaults: New build pipelines may prefer different module resolution or JSX settings.
- Your runtime changes: Moving from older JavaScript targets to a modern environment can simplify output and reduce transpilation complexity.
- Your project type changes: An internal app becoming a published package often requires declaration output and more explicit emit settings.
- Your strictness tolerance improves: Teams often adopt
strictfirst, then later add options likenoUncheckedIndexedAccessandexactOptionalPropertyTypes. - Your tooling starts disagreeing: If editor, test runner, bundler, and runtime resolve imports differently, your config likely needs simplification or alignment.
- Your monorepo grows: Shared base configs and package-specific overrides become more valuable as the number of packages increases.
A practical review routine is to check your tsconfig during these moments:
- when starting a new TypeScript project
- when upgrading TypeScript major or minor versions
- when changing framework or bundler
- when publishing a package for others to consume
- when recurring type errors suggest the config is either too loose or mismatched to runtime behavior
To keep the process manageable, use this short checklist:
- Confirm whether TypeScript should emit code or only type-check
- Confirm whether the project is bundler-first or runtime-first
- Keep
strictenabled unless migration constraints make that impossible - Add strictness flags deliberately, not all at once, in older codebases
- Keep path aliases and module settings aligned across every tool that loads your code
- Split base and per-project configs once duplication becomes noisy
- Remove options that no longer serve a clear purpose
The best tsconfig settings are not the most advanced ones. They are the ones your team can explain, maintain, and trust. If a config feels mysterious, it is probably time to simplify it.
For teams that regularly revisit foundational JavaScript and tooling decisions, it can also help to keep related references nearby, such as JavaScript Date Formatting Guide for runtime behavior decisions that affect app output and Markdown Previewer Tools for maintaining clear project documentation around config choices.
Start with a small, strict base. Match module settings to your actual runtime. Let your build tools do their job. Then update the file whenever your stack changes enough that the old assumptions no longer hold.