extends

Inherits compiler options from another tsconfig file, then overrides them locally.

Signature
{
  "extends": string | string[]
}
  • Added in TypeScript 2.1

Parameters

ParameterTypeDescription
$pathstringPath to another configuration file to inherit from. May be relative, or a Node.js-style package specifier such as "@tsconfig/strictest/tsconfig.json". The ".json" extension may be omitted for relative paths.
$pathsoptionalstring[]An array of the same, applied left to right as if each entry extended the one before it — later entries win on conflict. TypeScript 5.0 and above.

Errors

Circularity between configuration files is an error, as is a path that resolves to no file.

Changelog

  • PHP 5.0`extends` accepts an array of paths. Entries apply in order, as if the last extended the second-to-last, so later entries override earlier ones.
  • PHP 5.5The `${configDir}` template variable expands to the directory of the config doing the extending, so a shared base can finally express paths like "${configDir}/dist" that follow the consuming project.

The base file is loaded first, then the inheriting file's settings are laid on top. Package resolution works, so a base published to npm — @tsconfig/node20, @tsconfig/strictest — is referenced by specifier rather than by path.

The rule that catches everyone

Relative paths resolve relative to the file they were written in, not the file doing the extending. A monorepo base at the repo root containing "outDir": "./dist" gives every package the same root-level dist, and "include": ["src"] in that base points at the root's src. Since 5.5 the fix is "${configDir}/dist", which expands to the directory of whichever config is consuming the base. Before 5.5 the only option was to keep path-bearing options out of the base entirely.

Merge semantics

Compiler options merge key by key. files, include and exclude do not merge — if the child declares one, it replaces the base's version wholesale, so a child that sets include also throws away the base's exclude-shaped intent for that list. references is the one top-level property that is never inherited at all; each project must declare its own.

When to reach for it

Any repo with more than one tsconfig.json: a per-package config in a monorepo, or a tsconfig.build.json that extends the editor config and adds exclude for test files.

Example

Example · typescript
// packages/api/tsconfig.json
//   { "extends": ["@tsconfig/strictest/tsconfig.json", "../../tsconfig.base.json"],
//     "compilerOptions": { "outDir": "${configDir}/dist" },
//     "include": ["src"] }

interface User { id: string; email?: string }
const users: Record<string, User> = { u1: { id: "u1" } };

const found = users["u2"];
console.log("lookup:", found);                 // undefined
console.log("email:", found?.email ?? "none"); // none

// @tsconfig/strictest turns on noUncheckedIndexedAccess, so `found` is
// User | undefined and plain `found.email` fails:
//   error TS18048: 'found' is possibly 'undefined'.

When to use it

  • Give every package in a monorepo the same strictness from one root tsconfig.base.json
  • Layer @tsconfig/strictest on top of your house config without copying its options
  • Create a tsconfig.build.json that reuses the editor config but excludes *.test.ts

Frequently asked questions

Why does my shared tsconfig put every package's output in the same folder?

Because relative paths in a base config resolve against the base file's directory, so "outDir": "./dist" means the same folder for everyone. Write "${configDir}/dist" instead, which TypeScript 5.5 and later expands to the directory of the config that extends it.

Can extends take more than one config file?

Yes, since TypeScript 5.0: pass an array, and the entries apply left to right as if each extended the previous one, so the last entry wins any conflict.

Why is my base config's include being ignored?

Because files, include and exclude overwrite rather than merge — declaring any of them in the child config replaces the base's version entirely instead of adding to it.

More examples

A monorepo base and the path trap

The logged line is the same either way; the difference is where the .js file lands. With the plain `./dist`, billing's output is written to `<repo>/dist` alongside every other package's, and the packages quietly overwrite each other. With `${configDir}/dist` it goes to `packages/billing/dist`, which is what the base author meant.

Example · typescript
// tsconfig.base.json (repo root)
// {
//   "compilerOptions": {
//     "strict": true,
//     "outDir": "./dist",              <-- resolves to <repo>/dist for EVERYONE
//     "outDir": "${configDir}/dist"    <-- 5.5+: resolves per consuming package
//   }
// }

// packages/billing/tsconfig.json
// { "extends": "../../tsconfig.base.json", "include": ["src"] }

const invoice = { id: "INV-001", cents: 4250 };
console.log(`${invoice.id}: $${(invoice.cents / 100).toFixed(2)}`);
// INV-001: $42.50

Array form: later entries win

The printed line spells out the effective configuration. `module` survives from the first entry because nothing later touches it, `target` comes from the second entry because it conflicts and later wins, and `noEmit` comes from the file's own compilerOptions, which override both. Array order is inheritance order, not priority-first order.

Example · typescript
// tsconfig.json
// {
//   "extends": ["@tsconfig/node20/tsconfig.json", "./tsconfig.house.json"],
//   "compilerOptions": { "noEmit": true }
// }
//
// @tsconfig/node20 sets   "target": "es2022", "module": "nodenext"
// tsconfig.house.json sets "target": "es2023"
// Effective target: es2023 — the later entry overrides the earlier one.

const caps = { target: "es2023", module: "nodenext", noEmit: true };
console.log(Object.entries(caps).map(([k, v]) => `${k}=${v}`).join(" "));
// target=es2023 module=nodenext noEmit=true

Discussion

  • Be the first to comment on this lesson.