Blog

tsconfig | the missing docs

Key Takeaways

  • files and include select root files. exclude filters discovery through include, not imports or explicit files entries.
  • target, lib, and module answer different questions: output syntax, available API types, and module format.
  • paths describes aliases to TypeScript; it doesn't rewrite emitted imports or configure your runtime.
  • rootDir controls emitted directory structure, not project membership. Use outDir to separate generated files from source.
  • Use tsc --showConfig and tsc --explainFiles before guessing why a configuration behaves unexpectedly.

tsconfig.json tells TypeScript which files belong to a project, how to resolve their dependencies, how strictly to check them, and what to emit. The missing piece is usually how those settings interact—not another compiler option.

This guide connects the options you'll encounter in a small Node.js or browser project. It also updates advice that no longer holds: modern TypeScript defaults have changed, and baseUrl is no longer supported in TypeScript 7.0.

Start with a working tsconfig.json

Running tsc without input filenames searches the current directory and then parent directories for tsconfig.json. To remove ambiguity, select the configuration explicitly with tsc -p tsconfig.json; -p also accepts a directory containing that file. See TypeScript's project configuration documentation.

For a new Node.js project that emits JavaScript, initialize the package and install the compiler and Node.js declarations:

npm init -y
npm install --save-dev typescript @types/node
npm pkg set type=module

Create this layout:

project/
|-- package.json
|-- tsconfig.json
`-- src/
    |-- file1.ts
    |-- file2.ts
    `-- file3.ts

Save this as tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2023",
    "lib": ["ES2023"],
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "types": ["node"],
    "strict": true,
    "rootDir": "src",
    "outDir": "dist",
    "sourceMap": true,
    "noEmitOnError": true
  },
  "include": ["src/**/*"],
  "exclude": ["src/file2.ts"]
}

Create the following source files; the comments identify separate files:

// src/file1.ts
import { myFunc } from "./file3.js";
console.log(myFunc(2, 3));

// src/file2.ts
export const unused = true;

// src/file3.ts
export function myFunc(a: number, b: number): number {
  return a + b;
}

NodeNext models Node.js module behavior. With "type": "module" in the package, these files emit ES modules; TypeScript resolves ./file3.js to the source file3.ts, while the emitted import addresses the JavaScript file. The module reference explains this extension substitution.

npx tsc -p tsconfig.json && node dist/file1.js

The program prints 5. file1.ts and file3.ts are included; file2.ts isn't, because nothing imports it.

strict enables the strict checking family, including noImplicitAny, which reports implicit any types where the compiler requires a more specific type. types: ["node"] explicitly loads Node.js ambient declarations, such as its global names.

How files, include, and exclude interact

Think in two stages: select starting files, then follow dependencies. A root file is a starting point; a transitive dependency is reached through another file.

include patterns -- filter with exclude --+
                                          +-- root files
explicit files list ----------------------+
                                               |
                                               v
                              follow imports and references
                                               |
                                               v
                              complete TypeScript program
                              + library/type declarations

include discovers root files

Patterns are relative to the configuration file. src/**/* searches recursively, and a directory entry such as src/ also includes supported files recursively. The supported wildcards are *, ?, and **/.

Without allowJs, discovery includes supported TypeScript files rather than arbitrary assets. If files is absent, the default include is **/*; if files is present, the default include is empty.

exclude filters discovery, not the dependency graph

In the example, adding import "./file2.js"; to file1.ts brings file2.ts into the program despite its exclusion. Triple-slash references and type inclusions can also bring files in.

The default exclusions are node_modules, bower_components, jspm_packages, and the configured outDir. Supplying your own exclude replaces that list. None of these exclusions blocks an imported dependency.

files explicitly names root files

files accepts exact filenames, not glob patterns, and missing entries produce an error. When both files and include are specified, their root files are combined; exclude doesn't remove explicit files entries.

Consequently, adding "files": ["src/file3.ts"] keeps that file included even if you also add it to exclude. This isn't an override hierarchy: the settings participate in different stages. The TSConfig reference documents these discovery rules.

TypeScript target, lib, and module are separate decisions

OptionWhat it controlsWhat it doesn't provide
targetWhich JavaScript syntax is downleveled or preserved.Runtime APIs or general code optimization.
libBuilt-in API declarations available to the checker.Polyfills or implementations of those APIs.
moduleModule-format emission and related checking behavior.A bundler or runtime module loader.
moduleResolutionHow import specifiers are resolved to files and package declarations.Changes to the runtime's resolution rules.

Changing target also changes the default library selection, unless you explicitly set lib. For browser code, "lib": ["ES2023", "DOM"] supplies ECMAScript and browser API declarations; it doesn't make an older browser implement them.

ESNext is a moving target tied to the installed compiler, not a fixed stable language edition. Choose a dated target such as ES2023 when you want that syntax baseline to remain explicit across upgrades.

For direct Node.js execution, use a Node-aware module/resolution pair such as NodeNext/NodeNext. For a browser application processed by a bundler, Preserve/Bundler models that toolchain instead. Don't use bundler resolution merely to silence an import that Node.js cannot execute.

rootDir and outDir control output layout

rootDir is the source directory against which emitted paths are calculated. It doesn't select inputs or change include. An implementation file that needs emission outside rootDir produces an error; widening include doesn't fix that layout constraint.

With rootDir: "src" and outDir: "dist", the JavaScript paths look like this:

Source                         JavaScript output
src/file1.ts                   dist/file1.js
src/file3.ts                   dist/file3.js
src/libs/utils/math.ts         dist/libs/utils/math.js

Setting rootDir to "." instead retains the src/ segment below dist/. Modern TypeScript changed the configuration-based rootDir default to the directory containing the configuration, so explicitly setting "src" avoids unexpected extra nesting after an upgrade.

Use the rootDir reference for layout constraints and the outDir reference for output placement. Without outDir, emitted JavaScript is placed alongside source files.

paths aliases need agreement outside TypeScript

A paths mapping tells TypeScript how an existing alias resolves. Mapping targets are relative to the configuration file when baseUrl is absent. paths has not required baseUrl since TypeScript 4.1.

For an existing Vite browser application, this is a complete application tsconfig.json. It replaces the Node.js configuration above rather than extending it:

{
  "compilerOptions": {
    "target": "ES2023",
    "lib": ["ES2023", "DOM"],
    "module": "Preserve",
    "moduleResolution": "Bundler",
    "strict": true,
    "noEmit": true,
    "paths": {
      "mylibs/*": ["./src/libs/utils/*"]
    }
  },
  "include": ["src/**/*"]
}

Create src/libs/utils/math.ts with the same myFunc export used earlier. From src/pages/report.ts, the alias import is:

// Fragment: src/pages/report.ts
import { myFunc } from "mylibs/math";
console.log(myFunc(2, 3));

The equivalent relative import is:

// Alternative fragment: src/pages/report.ts
import { myFunc } from "../libs/utils/math";
console.log(myFunc(2, 3));

Configure the bundler too. Assuming Vite is installed, save this as vite.config.mjs:

import { fileURLToPath } from "node:url";
import { defineConfig } from "vite";

export default defineConfig({
  resolve: {
    alias: {
      mylibs: fileURLToPath(new URL("./src/libs/utils", import.meta.url))
    }
  }
});

Vite's resolve.alias supplies the build-time mapping; its filesystem replacements should be absolute paths. Keep any separately configured test runner's aliases consistent too.

paths does not rewrite emitted import specifiers. This example uses noEmit, but even if TypeScript emitted ES modules, the specifier would remain mylibs/math until another tool handled it.

You don't necessarily need a bundler: Node.js provides package.json imports with #-prefixed names, while browsers support import maps. Those are environment-specific mechanisms, not automatic interpretations of tsconfig.json. For more context, see our guide to JavaScript build tools.

Choose whether TypeScript emits JavaScript

  • noEmit: true suppresses JavaScript, declaration, and source-map emission. Use it when another tool produces the application output.
  • noEmitOnError: true prevents those outputs when TypeScript reports errors. For ordinary tsc -p builds, its default is false.

Neither option makes an independent bundler wait for type checking. Make the type-check command a required build or CI step. Our TypeScript versus Babel comparison explains the distinction between checking types and transforming syntax.

Three other output options from older configurations remain worth understanding:

  • sourceMap emits separate source-map files for debugging generated JavaScript against source.
  • removeComments removes comments from emitted JavaScript; it isn't a minifier.
  • preserveConstEnums preserves runtime declarations for const enums rather than erasing those declarations.

allowJs supports incremental JavaScript migration

allowJs lets JavaScript implementation files join the project alongside TypeScript files. Add checkJs to report errors in those JavaScript files; // @ts-check enables checking per file.

For migration, start with allowJs and noEmit, add useful JSDoc annotations, and enable checking incrementally. If you later emit JavaScript, keep output in a separate directory.

maxNodeModuleJsDepth controls how deeply TypeScript loads JavaScript dependencies under node_modules when allowJs is enabled. Its default is 0; prefer package declarations over increasing it, because deeper inference costs time and can reduce accuracy.

Share configuration with extends

Put shared checking policies in a base file, then extend it from each application. For example, save this as tsconfig.base.json:

{
  "compilerOptions": {
    "strict": true,
    "target": "ES2023"
  }
}

Add "extends": "./tsconfig.base.json" at the top level of the browser configuration above. The extends rules matter when a configuration is inherited:

  • The child overrides inherited compiler options individually; overriding an array such as lib replaces it rather than appending entries.
  • Child files, include, and exclude values each replace the corresponding inherited value.
  • Relative paths remain relative to the configuration where they were originally written—not the child configuration.
  • references isn't inherited.

Keep application-specific source and output paths in the application configuration. That avoids a shared base accidentally pointing every package at the same directory.

Use project references when one project becomes several

extends shares settings; project references describe dependencies between separately configured projects. Referenced build projects need composite: true and declaration emission, and their implementation files must be listed through files or matched by include.

For a multi-package build, put package configuration paths in a solution configuration's references, use files: [] there to avoid discovering another set of roots, and run tsc -b. Build mode checks which projects are out of date and builds dependencies in order; ordinary tsc -p doesn't automatically build referenced dependencies.

Diagnose the configuration before changing it

Run these commands against the same configuration your build uses:

npx tsc --version
npx tsc -p tsconfig.json --showConfig
npx tsc -p tsconfig.json --noEmit --listFiles
npx tsc -p tsconfig.json --noEmit --explainFiles
npx tsc -p tsconfig.json --noEmit --traceResolution
  • --showConfig prints the resolved configuration after inheritance instead of building.
  • --listFiles lists program files, including declarations.
  • --explainFiles adds why each file entered the program.
  • --traceResolution shows module lookup decisions, which helps distinguish a bad mapping from a runtime-only alias problem.

The compiler CLI reference documents command-line options. Check the selected configuration and compiler version before adding another workaround.

FAQ

What happens if I omit files, include, and exclude?

TypeScript discovers supported files recursively below the configuration directory through the default include pattern, **/*. Default exclusions cover node_modules, bower_components, jspm_packages, and the configured outDir. Imported dependencies can still enter the program.

Why does a file in exclude still get compiled?

exclude filters discovery through include; it isn't a prohibition on project membership. Imports, triple-slash references, type inclusions, or an explicit files entry can still bring the file into the program.

Do TypeScript paths aliases require baseUrl?

No. paths has worked without baseUrl since TypeScript 4.1, and mapping targets resolve relative to the configuration file when baseUrl is absent. TypeScript 7.0 no longer supports baseUrl.

Does paths rewrite imports in emitted JavaScript?

No. paths only informs TypeScript's resolution, so configure the runtime, bundler, or test runner to understand the same specifiers. Without a bundler, use an appropriate runtime mechanism, such as Node.js package imports or browser import maps.

How can I see which files TypeScript includes in a project?

Run npx tsc -p tsconfig.json --noEmit --listFiles to list program files, or replace --listFiles with --explainFiles to see why they were included. Use --showConfig separately to inspect the resolved configuration after inheritance.

Further Reading

Written by Sam Erickson

Software engineer. I write every article on StackChief myself. Author of Mastering the Tech Interview. More about me