tsconfig.json Best Practices: Recommended Settings for Apps, Libraries, and Monorepos
tsconfigtypescript compiler optionstoolingmonorepoproject setup

tsconfig.json Best Practices: Recommended Settings for Apps, Libraries, and Monorepos

TTypeScript Website Editorial
2026-06-08
9 min read

A practical checklist for choosing and maintaining tsconfig.json settings for apps, libraries, and monorepos.

A good tsconfig.json does two jobs at once: it helps developers move quickly today, and it prevents expensive cleanup later. This guide is a reusable checklist for choosing sensible TypeScript compiler options for applications, libraries, and monorepos. Instead of treating tsconfig as boilerplate, it explains which settings matter, why they matter, and what to review when your tooling, runtime, or package layout changes.

Overview

If you only copy one idea from this article, let it be this: tsconfig.json should reflect the kind of project you are building, not a generic internet snippet. A frontend app, a published library, and a monorepo package all have different constraints. The right settings for one can create friction in another.

At a high level, a maintainable TypeScript project setup usually follows four rules:

  1. Start with strictness. It is easier to relax a specific rule than to recover type safety after months of permissive defaults.
  2. Separate type-checking concerns from emit concerns. Many teams now use a bundler, framework, or runtime tool for output, while TypeScript focuses on correctness.
  3. Share a base config where possible. This matters most in monorepos, but even a single repo benefits from a stable baseline.
  4. Keep config reviewable. A shorter, intentional config is easier to maintain than a long file full of cargo-cult options.

These best practices are especially useful if you are migrating from JavaScript, standardizing a team-wide typescript project setup, or trying to reduce confusing type errors caused by inconsistent compiler settings.

A practical mental model is to group compiler options into five buckets:

  • Safety: strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes
  • Interop: module, moduleResolution, esModuleInterop, allowSyntheticDefaultImports
  • Output: target, lib, sourceMap, declaration, outDir
  • Project boundaries: include, exclude, rootDir, composite, references
  • Developer experience: incremental, skipLibCheck, path aliases, and file organization

If you want a broader refresher on syntax and utility types before tuning your compiler options, see TypeScript Cheat Sheet: Syntax, Utility Types, and Everyday Patterns.

Checklist by scenario

Use this section as the main working checklist. The goal is not to copy every option blindly, but to choose a stable baseline for your scenario.

This is a strong starting point for internal applications, backend services, CLI tools, and many frontend apps where a framework or bundler handles output.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "useUnknownInCatchVariables": true,
    "noFallthroughCasesInSwitch": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "resolveJsonModule": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "incremental": true
  },
  "include": ["src"]
}

What this baseline gets right:

  • strict: true enables the core set of safety checks. For most teams, this should be the default, not an aspiration.
  • noUncheckedIndexedAccess catches a common source of runtime bugs when indexing arrays, records, or object maps.
  • exactOptionalPropertyTypes makes optional properties behave more precisely and helps avoid subtle API shape errors.
  • isolatedModules keeps your code compatible with single-file transpilation workflows used by many toolchains.
  • verbatimModuleSyntax makes imports and exports more explicit and reduces surprising module rewrites.
  • skipLibCheck is often a reasonable performance tradeoff for apps, especially when external type packages are noisy but not actionable.
  • noEmit: true is appropriate when another tool produces build output.

When to adjust it:

  • Use a different moduleResolution if your runtime or toolchain expects Node-style resolution instead of bundler-style behavior.
  • Set lib explicitly if your app targets a constrained environment.
  • Turn on declaration output only if you are actually publishing types for consumption elsewhere.

Libraries need a different kind of discipline. Your consumers depend on your emitted declarations, your module format choices, and your package boundary clarity.

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "declaration": true,
    "declarationMap": true,
    "emitDeclarationOnly": true,
    "stripInternal": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "rootDir": "src",
    "outDir": "dist/types"
  },
  "include": ["src"]
}

For libraries, double emphasis belongs on:

  • declaration and declarationMap, because your type output is part of the product.
  • rootDir and outDir, because a clean file layout reduces packaging mistakes.
  • emitDeclarationOnly when JavaScript output is handled by another bundling step.
  • Stable public types, because TypeScript users experience your API through editor hints as much as through documentation.

Library-specific caution: path aliases can be convenient inside source code, but they can complicate publishing unless your build process rewrites them correctly. For a library, simpler import paths are often safer than clever local abstractions.

Backend projects usually need a little more clarity around modules, runtime behavior, and emitted output.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "useUnknownInCatchVariables": true,
    "noFallthroughCasesInSwitch": true,
    "sourceMap": true,
    "outDir": "dist",
    "rootDir": "src",
    "resolveJsonModule": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "incremental": true
  },
  "include": ["src"]
}

This setup favors projects that want TypeScript to emit runnable output. The important part is consistency between your package configuration, your runtime, and your module settings. Many hard-to-debug backend issues are really module mismatch issues.

Framework-managed projects often work best when TypeScript handles types and the framework handles build output.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "jsx": "react-jsx",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true,
    "resolveJsonModule": true,
    "skipLibCheck": true,
    "noEmit": true,
    "incremental": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  },
  "include": ["src"]
}

For frontend projects, keep aliases modest and meaningful. One top-level alias such as @/ is often enough. Too many aliases make refactoring harder and can blur package boundaries in larger repos.

A typescript monorepo tsconfig should usually be layered. The root config defines shared defaults, and each package extends it.

Root base config:

{
  "compilerOptions": {
    "target": "ES2022",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "useUnknownInCatchVariables": true,
    "noFallthroughCasesInSwitch": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "incremental": true
  }
}

Package config for an app:

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "noEmit": true
  },
  "include": ["src"]
}

Package config for a library:

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "declaration": true,
    "declarationMap": true,
    "emitDeclarationOnly": true,
    "composite": true,
    "rootDir": "src",
    "outDir": "dist/types"
  },
  "include": ["src"]
}

Why this matters:

  • Shared strictness prevents package-to-package inconsistency.
  • Package-specific emit settings keep apps and libraries from fighting over the same assumptions.
  • composite and project references can improve build coordination when your repo is large enough to justify them.

In monorepos, resist the urge to solve every local import inconvenience with path aliases. If packages represent real boundaries, use package imports where possible. It keeps the dependency graph more honest.

What to double-check

Before you finalize a tsconfig.json example in a real codebase, review these points. Most persistent TypeScript tooling problems come from one of them.

Match your module settings to your runtime

module and moduleResolution should reflect how code is actually executed or bundled. If your runtime expects Node-flavored ESM behavior, use settings that align with that. If a bundler resolves modules for you, bundler-oriented settings are often a better fit.

Decide whether TypeScript should emit files

If your framework, bundler, or build tool handles output, keep noEmit: true. If TypeScript is part of your production build pipeline, define outDir, rootDir, and source map behavior explicitly.

Keep include and exclude narrow

Broad globs can pull test fixtures, generated files, temporary scripts, or build output into type-checking. A focused include like ["src"] is usually easier to reason about.

Review strictness options individually

strict is the main switch, but teams often benefit from deciding deliberately on related options. If a migration is in progress, you can document temporary exceptions, but make them explicit. Silent permissiveness tends to become permanent.

Check editor and CI behavior together

TypeScript friction often comes from differences between local editor settings and CI commands. Make sure the command your team runs in CI is the same one people rely on during development.

Be cautious with path aliases

Aliases are useful, but they affect bundlers, test runners, linting, and editor resolution. Every alias adds another place where configuration can drift. Use them to reduce meaningful complexity, not just to shorten import strings.

Common mistakes

This is the section to revisit when a project feels harder to maintain than it should.

Treating tsconfig as a one-time setup file

tsconfig.json is part of your architecture. It should evolve when your package format, runtime, repo layout, or framework changes. A config written for an early prototype may be a poor fit for a mature application.

Using library settings in an app, or app settings in a library

Apps can often tolerate skipLibCheck and noEmit without much risk. Libraries need more discipline around declarations, packaging, and public API boundaries. Mixing the two mindsets creates subtle problems.

Turning off strictness to “fix” type errors

Disabling safety checks can make a build go green, but it rarely solves the underlying problem. A better approach is to localize the issue, improve the type model, or add a narrow escape hatch with a comment and a reason.

Letting generated files leak into source checking

Generated clients, build output, and codegen folders can create noise and slow down the compiler. Decide whether they belong in the checked source set and configure boundaries accordingly.

Overusing skipLibCheck as a universal escape hatch

skipLibCheck can be reasonable, especially in applications, but it should be a considered performance choice rather than a reflex. If your own declarations are wrong, this option will not save you from downstream pain.

Ignoring file casing and cross-platform differences

forceConsistentCasingInFileNames is easy to overlook until a project behaves differently across developer machines or CI. Keep it on.

Hiding package boundaries in a monorepo

If everything imports everything else through broad aliases, the repo stops communicating intent. It becomes harder to understand ownership, build order, and dependency direction. Real boundaries deserve visible imports.

If your team is building tooling-heavy internal platforms or analysis workflows, this discipline becomes even more important. Related reading on maintainable TypeScript tooling can be found in From mined rules to developer acceptance: shipping static analysis rules for the TypeScript ecosystem and A language-agnostic static analysis model in TypeScript: implementing a MU-like graph representation.

When to revisit

Use this final checklist whenever the underlying assumptions of your project change. That is the simplest way to keep your TypeScript compiler options healthy over time.

  1. When you upgrade TypeScript. New releases can introduce better defaults, clearer module behavior, or stricter options worth enabling.
  2. When you change build tools. Moving between bundlers, framework versions, or runtime loaders often changes which module and emit settings make sense.
  3. When you split a repo into packages. A single-project config usually does not scale cleanly into a monorepo without a shared base and per-package overrides.
  4. When you publish code for external use. Internal app settings are not enough for a public library. Revisit declarations, output structure, and import paths.
  5. When type-checking gets slow or noisy. Review include patterns, generated files, project references, and whether your config still matches the current workflow.
  6. Before planning cycles or team-wide tooling changes. This is a good moment to standardize compiler settings before inconsistency spreads across new work.

A practical maintenance routine is simple:

  • Keep one documented base config for shared standards.
  • Store a short note beside unusual compiler options explaining why they exist.
  • Review the config at the same time you review linting, testing, and build pipeline changes.
  • Prefer small, intentional updates over infrequent rewrites.

The best tsconfig best practices are not the most elaborate ones. They are the settings your team understands, applies consistently, and revisits when the project changes. If you treat tsconfig.json as a living part of your engineering toolkit, it becomes much easier to build reliable applications, maintain libraries, and scale a TypeScript codebase without unnecessary friction.

Related Topics

#tsconfig#typescript compiler options#tooling#monorepo#project setup
T

TypeScript Website Editorial

Senior SEO Editor

Senior editor and content strategist. Writing about technology, design, and the future of digital media. Follow along for deep dives into the industry's moving parts.

2026-06-08T06:33:41.486Z