# Understanding tsconfig.json

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

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-09-12 | Topics: [TypeScript](https://flaviocopes.com/tags/typescript/) | Canonical: https://flaviocopes.com/tsconfig-explained/

`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 6.x release in the project:

```bash
npm install -D typescript@6
```

Then create a starting file:

```bash
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](https://flaviocopes.com/courses/typescript/) before tuning compiler flags.

## The shape of tsconfig.json

Most settings live under `compilerOptions`:

```json
{
  "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:

```json
{
  "compilerOptions": {
    "strict": true
  }
}
```

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

Here is the difference `strictNullChecks` makes:

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

console.log(user.name)
```

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

```ts
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:

```ts
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`:

```json
{
  "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:

```ts
type User = {
  nickname?: string
}
```

Without `exactOptionalPropertyTypes`, assigning `undefined` is generally allowed:

```ts
const user: User = {
  nickname: undefined,
}
```

With this option:

```json
{
  "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.

```json
{
  "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:

```json
{
  "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:

```json
{
  "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:

```json
{
  "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:

```json
{
  "compilerOptions": {
    "noEmit": true
  }
}
```

Run the check with:

```bash
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:

```json
{
  "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:

```json
{
  "compilerOptions": {
    "isolatedModules": true
  }
}
```

It does not perform the transform. It checks that each file can be handled by a tool that works independently.

## Keep imports explicit with verbatimModuleSyntax

Types disappear at runtime. Values do not.

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

```json
{
  "compilerOptions": {
    "verbatimModuleSyntax": true
  }
}
```

Use `import type` for a type:

```ts
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:

```json
{
  "compilerOptions": {
    "esModuleInterop": true
  }
}
```

Many framework templates enable it. In a fully modern ESM project it may not affect much, but older CommonJS dependencies can make the option useful.

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`.

```json
{
  "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:

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

Patterns are relative to the config file.

`exclude` removes matches from what `include` finds:

```json
{
  "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:

```json
{
  "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:

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

Now TypeScript can resolve this:

```ts
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

By default, visible `@types` packages can add global names to the project.

Use `types` when you want an explicit list:

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

This does not stop you importing other typed packages. It controls which packages contribute global declarations.

It is 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:

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

Then extend it:

```json
{
  "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:

```json
{
  "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"]
}
```

This is a starting point, not a universal answer.

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:

```bash
npx tsc --showConfig
```

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

To check a specific project file, use `-p`:

```bash
npx tsc -p tsconfig.json --noEmit
```

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

```bash
npx tsc src/index.ts
```

When input files are listed on the command line, TypeScript ignores the project config and uses command-line options instead.

That small detail explains 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, easier to upgrade, and easier to debug.

The language side matters too. [Type aliases and interfaces](https://flaviocopes.com/typescript-interfaces-vs-types/) explain how to model your application once the compiler is configured.
