Understanding tsconfig.json

By

Understand tsconfig.json options that matter: strict, target, lib, module resolution, noEmit, paths, include, and a modern starter configuration.

~~~

tsconfig.json defines a TypeScript project.

It tells the compiler which files belong to the project, how to resolve imports, which JavaScript features exist, how strict type checking should be, and whether TypeScript should write output files.

Without a config file, tsc uses defaults. Those defaults cannot know whether we are building a browser app with Vite, a Node.js service, or a library.

The right config starts with the runtime and build tool.

Create a config

Install the latest TypeScript 7.x release in the project:

npm install -D typescript@7

TypeScript 7 is the new Go-based compiler. It reads the same tsconfig.json as version 6 and applies the same defaults. It has no programmatic API yet, so projects that use Vue, Svelte, Astro or MDX still need TypeScript 6 for their editor tooling. Install typescript@6 there.

Then create a starting file:

npx tsc --init

The generated config includes comments and many options. You do not need to understand all of them at once.

I prefer a short config where every option has a reason to exist.

If TypeScript itself is new to you, start with the free TypeScript course before tuning compiler flags.

The shape of tsconfig.json

Most settings live under compilerOptions:

{
  "compilerOptions": {
    "strict": true,
    "noEmit": true
  },
  "include": ["src"]
}

compilerOptions controls checking, module handling, and output.

include, exclude, and files decide where the project begins looking for source files.

extends lets one config inherit another.

Turn on strict checking

Start with this:

{
  "compilerOptions": {
    "strict": true
  }
}

strict enables a family of checks, including strictNullChecks, noImplicitAny, and strictFunctionTypes.

Since TypeScript 6 it is already on when the option is missing. We still write it down, so a shared config or an old template can’t turn it off without anyone noticing.

Here is the difference strictNullChecks makes:

const user = users.find(user => user.id === requestedId)

console.log(user.name)

find() can return undefined. Strict checking makes us handle that case:

const user = users.find(user => user.id === requestedId)

if (!user) {
  throw new Error('User not found')
}

console.log(user.name)

My advice is to enable strict when the project starts. Turning it on after years of loose code creates a large migration instead of a daily safety net.

Add stricter indexed access when it helps

strict does not enable every safety option.

Consider an array lookup:

const names = ['Jack', 'Syd']
const first = names[0]

By default, first is typed as string, even though an index can be missing.

Enable noUncheckedIndexedAccess to include undefined:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true
  }
}

This also affects index signatures such as Record<string, User>. It catches real mistakes, but it adds checks in code that already controls the keys.

I like it in new applications. I introduce it separately in an existing project because it can produce many errors at once.

Decide what optional means

An optional property can be absent:

type User = {
  nickname?: string
}

Without exactOptionalPropertyTypes, assigning undefined is generally allowed:

const user: User = {
  nickname: undefined,
}

With this option:

{
  "compilerOptions": {
    "exactOptionalPropertyTypes": true
  }
}

“missing” and “present with the value undefined” stay distinct unless the property type explicitly includes undefined.

This is useful when object keys have runtime meaning, such as patch requests or configuration merging.

target controls emitted JavaScript

target tells TypeScript which JavaScript syntax it may leave in emitted files.

{
  "compilerOptions": {
    "target": "ES2022"
  }
}

An older target makes TypeScript rewrite more syntax. A modern target keeps more of the source intact.

Choose the oldest runtime you actually support. Do not lower the target from habit.

If noEmit is enabled, another tool writes the JavaScript. target still influences type checking and the default library definitions.

lib controls available platform APIs

lib tells TypeScript which built-in APIs exist in the target environment.

A browser application might use:

{
  "compilerOptions": {
    "lib": ["ES2022", "DOM", "DOM.Iterable"]
  }
}

ES2022 provides JavaScript language APIs. DOM provides browser globals such as document, fetch, and HTMLElement.

A Node.js service should not add DOM only to make a missing global error disappear. Install the Node types and configure the project for its real runtime.

target and lib answer related but different questions:

  • target controls JavaScript syntax in emitted code
  • lib controls which platform APIs TypeScript knows

Neither one adds a polyfill. If an old browser lacks an API, a type definition does not make it exist at runtime.

module and moduleResolution must match the toolchain

module controls how TypeScript treats and emits imports and exports.

moduleResolution controls how TypeScript finds the files behind those imports.

For an application bundled by Vite or another modern bundler, use bundler resolution:

{
  "compilerOptions": {
    "module": "preserve",
    "moduleResolution": "bundler"
  }
}

preserve keeps each import and export form for the bundler. bundler understands package exports and does not require JavaScript file extensions on relative TypeScript imports.

For a modern Node.js project that TypeScript emits directly, use the Node pair:

{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext"
  }
}

Node’s result also depends on file extensions and the nearest package.json type field.

Do not copy a browser bundler config into a Node service. Import rules should match the runtime that will execute the files.

Use noEmit when another tool builds the code

Vite, esbuild, Astro, Next.js, and similar tools usually handle JavaScript output.

In that setup, TypeScript only needs to check types:

{
  "compilerOptions": {
    "noEmit": true
  }
}

Run the check with:

npx tsc --noEmit

The command exits with an error when type checking fails and writes no .js files.

For a Node.js service or library emitted by tsc, omit noEmit and configure output instead:

{
  "compilerOptions": {
    "rootDir": "src",
    "outDir": "dist",
    "declaration": true
  }
}

declaration is useful for a published library. An internal application normally does not need .d.ts output.

Use isolatedModules with a per-file compiler

Bundlers transform one file at a time. They do not have the same whole-program information as tsc.

This option warns about TypeScript patterns that cannot be transformed safely in isolation:

{
  "compilerOptions": {
    "isolatedModules": true
  }
}

It does not perform the transform, it only checks that a tool working on one file at a time can handle each file.

Keep imports explicit with verbatimModuleSyntax

Types disappear at runtime, while values stay in the JavaScript output.

verbatimModuleSyntax keeps import behavior predictable and asks us to mark type-only imports:

{
  "compilerOptions": {
    "verbatimModuleSyntax": true
  }
}

Use import type for a type:

import type { User } from './user'
import { loadUser } from './user'

The first import can be removed from the JavaScript output. The second must remain because the program calls it at runtime.

This option works well in modern ESM projects. Check a framework’s recommended config before changing an established project.

esModuleInterop helps with CommonJS packages

Some packages use CommonJS exports while our source uses ESM imports.

esModuleInterop adds compatibility helpers when TypeScript emits modules and changes how some default imports are checked:

{
  "compilerOptions": {
    "esModuleInterop": true
  }
}

Since TypeScript 6 this option is always on, and setting it to false is an error. You will still find the line in many framework templates. It is harmless there.

Do not treat it as a general fix for every import error. First check the package’s export format and the project’s module settings.

Understand skipLibCheck

skipLibCheck skips type checking inside declaration files, including many files under node_modules.

{
  "compilerOptions": {
    "skipLibCheck": true
  }
}

This can make application checks faster and avoid errors caused by conflicting dependency declarations.

It does not skip checking how your code uses those declarations.

The tradeoff is that broken or duplicated library types can stay hidden. I often enable it in an application. I am more cautious in a library whose job is to publish types to other projects.

include, exclude, and files

include defines the starting set of project files:

{
  "include": ["src", "vite.config.ts"]
}

Patterns are relative to the config file.

exclude removes matches from what include finds:

{
  "exclude": ["dist", "coverage"]
}

Be careful: exclude is not a firewall. If an included file imports an excluded file, TypeScript can still add that dependency to the project.

Use files when you want an explicit list of entry files:

{
  "files": ["src/index.ts"]
}

Most applications use include. An exact files list is more common in small configs and carefully structured packages.

paths creates TypeScript aliases

Aliases can replace long relative imports:

{
  "compilerOptions": {
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

Now TypeScript can resolve this:

import { Button } from '@/components/Button'

But paths does not rewrite the import in emitted JavaScript. Your bundler or runtime needs the same alias.

This is a common mistake: the editor accepts the path, then the production process cannot load it.

Configure the alias in the build tool too, or use a framework feature that reads tsconfig.json paths for you.

types limits global type packages

Since TypeScript 6 the default for types is an empty list. An installed @types package no longer adds its global names to the project on its own.

List the packages whose globals you need:

{
  "compilerOptions": {
    "types": ["node"]
  }
}

It only controls which packages contribute global declarations, so you can still import other typed packages.

So a fresh project can say it cannot find process or Buffer even though @types/node is installed. Add "node" to the list. Set it to ["*"] if you want the old behavior back, where every visible @types package contributes its globals.

It is also useful when test frameworks or multiple runtimes add globals with the same names.

Share settings with extends

Large repositories often need more than one config.

Put shared checks in a base file:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true
  }
}

Then extend it:

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

Keep runtime-specific options in the child config. A browser app and Node.js script can share strictness without pretending they have the same globals or module rules.

A starter config for a bundled browser app

Here is the config I would start with for a modern browser application using a bundler:

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "preserve",
    "moduleResolution": "bundler",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true,
    "noEmit": true,
    "skipLibCheck": true,
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src", "vite.config.ts"]
}

Treat it as a starting point.

I would remove paths if the project does not need aliases. I would also use the framework’s generated config when it provides one, then add strictness deliberately.

Check which config TypeScript sees

Show the final config after extends and defaults are applied:

npx tsc --showConfig

This is useful when an option seems to have no effect.

To check a specific project file, use -p:

npx tsc -p tsconfig.json --noEmit

Do not pass source filenames when you expect tsconfig.json to apply:

npx tsc src/index.ts

When input files are listed on the command line, TypeScript does not read the project config. It only uses command-line options. Since TypeScript 6 that is an error when a tsconfig.json sits in the folder, and tsc asks for an explicit --ignoreConfig flag if you really want it.

Older versions compiled the file anyway and ignored the config in silence, which explained many “the option is enabled but nothing changed” problems.

How I tune a config

I begin with the runtime: browser, Node.js, or both. Then I identify who emits JavaScript: tsc or another build tool.

After that I enable strict, choose the matching module strategy, and keep the file list small. Extra safety flags come next.

I do not copy a giant config and hope every option helps. A short config is easier to review, upgrade and debug.

The language side matters too. Type aliases and interfaces explain how to model your application once the compiler is configured.

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about typescript: