Developer Tools

Structuring TSConfig: The Modern Strict Configuration Cheat Sheet

By DexNox Dev Team Published May 12, 2026

TypeScript’s default configurations prioritize backwards compatibility, allowing codebases to compile with minimal typing constraints. However, this permissive behavior permits unhandled null values, implicit types, and unresolved module imports. Building a strict, production-ready tsconfig.json requires configuring specific module resolution, type safety, and path mapping flags.

Module Resolution and Compiler Targets

Modern runtimes like Node.js 22, Bun, and Deno support native ECMAScript Modules (ESM) and modern ESNext features. When configuring your compiler, choose your target module resolution settings carefully:

  • moduleResolution: "Bundler": The recommended option for applications compiled using modern bundlers (e.g., Vite, Rspack, esbuild) or executed inside Bun. It mimics the resolution strategies of modern bundlers, permitting bare specifiers and supporting path aliases.
  • moduleResolution: "NodeNext": The correct configuration for libraries running natively in Node.js without bundlers. It enforces strict ESM rules, requiring relative imports to include file extensions (e.g., import { helper } from "./helper.js").
  • moduleResolution: "Node10": A legacy option that resolved dependencies using older CommonJS algorithms. It must be avoided in modern codebases.

The Production-Grade Strict TSConfig

The configuration below establishes a strict type-safety contract. It combines the strict suite with advanced compiler checks that are disabled by default:

{
  "$schema": "https://json.schemastore.org/tsconfig",
  "compilerOptions": {
    "target": "ESNext",
    "lib": ["ESNext"],
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "moduleDetection": "force",
    "verbatimModuleSyntax": true,

    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictBindCallApply": true,
    "strictPropertyInitialization": true,
    "noImplicitThis": true,
    "useUnknownInCatchVariables": true,

    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitOverride": true,
    "noUncheckedIndexedAccess": true,

    "esModuleInterop": false,
    "allowSyntheticDefaultImports": true,
    "forceConsistentCasingInFileNames": true,
    "isolatedModules": true,
    "resolveJsonModule": true,
    "skipLibCheck": true,

    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"],
      "@config/*": ["./src/config/*"],
      "@lib/*": ["./src/lib/*"]
    },

    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Key Configuration Flag Details

ESM Import Guarding (verbatimModuleSyntax)

Setting "verbatimModuleSyntax": true forces developers to explicitly state type imports using the import type syntax. Without this check, compilers will output value imports for types. This results in empty require statements or circular dependency loops at runtime inside ESM contexts that lack dynamic dependency resolution.

// Error: verbatimModuleSyntax detects type import styled as value import
import { AppConfig, initApp } from "./config.js";

// Correct: Explicit type and value separation
import type { AppConfig } from "./config.js";
import { initApp } from "./config.js";

Safe Array Access (noUncheckedIndexedAccess)

By default, TypeScript assumes that accessing an array by index returns the declared item type, even if the index is out of bounds. Enabling "noUncheckedIndexedAccess": true appends undefined to the return type of all index signature lookups, forcing developers to implement safety checks:

const names: string[] = ["Alice", "Bob"];

// Without noUncheckedIndexedAccess: type is string
const user = names[5];
console.log(user.toUpperCase()); // Crashes at runtime (Cannot read property of undefined)

// With noUncheckedIndexedAccess: type is string | undefined
const safeUser = names[5];
if (safeUser) {
  console.log(safeUser.toUpperCase());
}

Exact Property Constraints (exactOptionalPropertyTypes)

This flag prevents developers from assigning undefined to an optional property unless undefined is explicitly listed in the property type definition. This preserves correct configurations in structures where an absent key behaves differently than a key containing an undefined value:

interface DBConfig {
  timeout?: number;
}

// Error under exactOptionalPropertyTypes: key cannot be set to undefined
const db: DBConfig = { timeout: undefined };

// Correct: Key is omitted from the object instance
const safeDb: DBConfig = {};

Summary of Critical Type Checking Flags

Compiler FlagDefaultRecommendedImpact on Verification
strictfalsetrueEnables base set of type checks
noImplicitAnyfalsetrueErrors when type annotations resolve to any
strictNullChecksfalsetrueIsolates null and undefined from concrete types
noUncheckedIndexedAccessfalsetrueEnforces checks on array index accesses
exactOptionalPropertyTypesfalsetrueEnforces absent keys over undefined keys
verbatimModuleSyntaxfalsetrueEnforces explicit type imports for bundlers

IDE Navigation: Declaration Maps and Source Maps

In a multi-project workspace or monorepo, developers import code from local packages. By default, when you trigger “Go to Definition” in an IDE, the editor navigates to the compiled declaration file (.d.ts), hiding the original source code implementation.

Enabling both "declaration": true and "declarationMap": true solves this issue:

  • declaration: Generates .d.ts files containing type assertions for consuming modules.
  • declarationMap: Generates source map files (.d.ts.map) mapping the compiled declaration files back to the original TypeScript source files.
  • sourceMap: Generates execution source maps (.js.map) mapping the compiled JavaScript code back to the original source code, enabling debuggers to pause on breakpoints inside the raw TypeScript files.
TypeScript Source (.ts) ──▶ Compiler Execution (tsc) ──▶ Compiled Code (.js) + sourceMap (.js.map)

                                   └────────────────────▶ Type Declarations (.d.ts) + declarationMap (.d.ts.map)

Runtime-Specific Profiles Using extends

Instead of duplicating settings across individual workspaces, establish a shared base file and extend it using runtime-specific configs.

Shared Base Profile (tsconfig.base.json)

{
  "$schema": "https://json.schemastore.org/tsconfig",
  "compilerOptions": {
    "target": "ESNext",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitReturns": true,
    "noImplicitOverride": true,
    "verbatimModuleSyntax": true,
    "isolatedModules": true,
    "forceConsistentCasingInFileNames": true,
    "skipLibCheck": true
  }
}

Node.js 22 Server Profile (tsconfig.node.json)

{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "lib": ["ESNext"],
    "target": "ES2023",
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Vite Frontend Application Profile (tsconfig.app.json)

{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "lib": ["ESNext", "DOM", "DOM.Iterable"],
    "jsx": "react-jsx",
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src/**/*", "vite.config.ts"],
  "exclude": ["node_modules", "dist"]
}

Bun Execution Profile (tsconfig.bun.json)

{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "lib": ["ESNext"],
    "types": ["bun-types"]
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Path Aliasing Resolution across Platforms

TSConfig paths configurations only instruct the type checker how to resolve module declarations. They do not rewrite import statement path strings inside emitted JavaScript files.

1. Vite Aliasing Configuration (vite.config.ts)

Map the corresponding aliases inside Vite to enable correct module bundling:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { resolve } from "node:path";

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      "@": resolve(__dirname, "./src"),
      "@config": resolve(__dirname, "./src/config"),
      "@lib": resolve(__dirname, "./src/lib"),
    },
  },
});

2. Node.js Script Execution using tsx

To run TypeScript files natively in Node.js while supporting path mappings, execute scripts using tsx:

npx tsx --tsconfig tsconfig.node.json src/index.ts

tsx reads your target configuration file and resolves path mappings dynamically in-memory without extra loader libraries.

3. Bun Native Execution

Bun reads tsconfig.json path mappings natively. Run Bun scripts directly without helper tools:

bun run src/index.ts

What Breaks in Production

Dynamic Import Path Failures

Using the paths alias configurations (e.g., "@/*") inside tsconfig.json does not rewrite import statements during compilation. The TypeScript compiler only uses these aliases to check type validity. If the compiled JavaScript is executed directly in Node.js without a bundler, the runtime will fail to locate the module, throwing a module resolution exception.

  • Resolution: Use bundler-level path resolution configurations (like Vite’s resolve.alias or Rspack’s resolve.alias) to rewrite the paths during packaging. For raw Node.js script execution, run the process using loaders like tsx that resolve TSConfig aliases dynamically.

Const Enum Compilation Failures

Setting "isolatedModules": true is required by modern compilers like esbuild and Vite because they compile files independently without performing whole-program analysis. This restriction causes compilation failures when using const enum definitions, as the compiler cannot inline the enum values across file boundaries.

  • Resolution: Refactor const enum definitions into standard enum types or use string union declarations:
    export type DBEngine = "postgres" | "redis";
    

Express Middleware Unused Arguments

Enabling "noUnusedParameters": true will block compilation when Express middleware signatures declare unused variables. Express routes depend on the parameter count to match the middleware signature (e.g., requiring the next callback to be listed in the arguments list even if it is not executed).

  • Resolution: Prefix unused arguments with an underscore (e.g., _next), telling the compiler to skip the parameter validation checks for those variables:
    app.use((err: Error, req: Request, res: Response, _next: NextFunction) => {
      res.status(500).json({ error: err.message });
    });
    

Strict Property Initializers in Entity Models

Using "strictPropertyInitialization": true requires all class properties to be initialized inside the class constructor. When using Object-Relational Mappers (such as TypeORM or Prisma) that assign property values dynamically during database hydration, the compiler will throw errors because the properties lack default values.

  • Resolution: Use the definite assignment assertion operator (!) to inform the compiler that the properties will be initialized externally by the database driver:
    class UserEntity {
      id!: number;
      email!: string;
    }
    

Frequently Asked Questions

Why does esModuleInterop break some native ESM imports?

Enabling esModuleInterop generates namespace helper functions to support default imports from CommonJS modules. In pure ESM workspaces executing on Node.js 22, this helper can conflict with strict runtime resolution rules, so it is recommended to keep it disabled.

What is the difference between target and module in tsconfig?

The target property defines the version of JavaScript syntax output by the compiler (e.g., ES2022). The module property specifies the module loading system output by the compiler (e.g., CommonJS or ESM).

How do I configure my workspace to exclude test files from compilation?

Use the exclude array configuration inside your root tsconfig.json to ignore test files (e.g., "exclude": ["**/*.test.ts", "**/*.spec.ts", "dist/"]). This prevents the build process from emitting type declaration files for test utilities.

Frequently Asked Questions

How do path alias maps in tsconfig affect compiler outputs?

TSConfig path aliases only provide mapping information to IDE tools and the TypeScript type checker. You need matching resolver rules in your bundlers (Vite/Esbuild) or Node loaders to translate these at build time. Without bundler config, aliases compile but produce broken import paths at runtime.

Can a child tsconfig file extend rules from a shared NPM base?

Yes, you can configure your configuration files to extend a shared base package. Specify the package name or local file path inside the extends property of your child configuration file.

What does verbatimModuleSyntax actually prevent?

It forces you to use `import type` for type-only imports. Without it, TypeScript can silently emit value imports that become empty requires at runtime, causing crashes or incorrect tree-shaking in bundlers that expect ESM. It's especially important for libraries and projects targeting Bun or native ESM.

Why does enabling strict mode break existing code that compiled fine before?

The strict flag activates a group of specific compiler checks that are disabled by default. These checks enforce strict type narrowing, restrict implicit any assignments, and mandate property initialization in classes.