GAP·MAP

TypeScript configuration

[ junior depth ]

What tsconfig.json owns

A tsconfig.json marks the root of a TypeScript project. It tells the compiler which files belong to that project and which compiler options apply. Running tsc without input filenames searches for a config from the current directory upward. Passing source filenames on the command line causes tsc to ignore tsconfig.json, which can explain why a local command behaves differently from the project build.

This small browser application config separates four decisions:

{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["ES2020", "DOM"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "noEmit": true
  },
  "include": ["src/**/*.ts", "src/**/*.tsx"]
}

include supplies root files through patterns. Imported files can still join the program even when an exclude pattern would have kept them out of the include search. exclude is a filter for that search, not a firewall around the project.

target and lib answer different questions

target controls which JavaScript syntax TypeScript downlevels and which syntax remains in emitted output. For example, an ES5 target converts arrow functions to function expressions. Changing target also changes the default lib, although you can set lib yourself.

lib selects type definitions for built-in JavaScript and host APIs. DOM tells TypeScript about names such as document; an ECMAScript library adds definitions for APIs from that edition.

Wrong "Adding ES2023 to lib makes an old runtime support ES2023 APIs."

Right lib changes the APIs TypeScript knows about. Choose it to describe APIs that the runtime or supplied polyfills provide. target handles syntax emission.

Start strict and make exceptions visible

strict: true enables the current strict-family checks. The family includes checks for implicit any, nullable values, function assignments, class-property initialization, and several narrower unsafe cases. TypeScript may add stricter checking to this family in a future release, so a compiler upgrade can reveal new errors.

You can disable one family member while leaving the others enabled:

{
  "compilerOptions": {
    "strict": true,
    "strictPropertyInitialization": false
  }
}

That exception is different from setting strict: false, which changes the default for the whole family.

Wrong "strict means TypeScript validates every runtime value."

Right the flag strengthens static checks for the program TypeScript can analyze. Data from a network, file, or user still needs runtime validation before the program relies on its shape.

The compiler can check without emitting

With noEmit: true, TypeScript produces no JavaScript, source maps, or declarations. It still acts as the source-code type checker. This is a common split when a bundler converts the .ts files into runnable JavaScript:

{
  "scripts": {
    "typecheck": "tsc --noEmit",
    "build": "vite build"
  }
}

The two commands have separate jobs. A successful bundle does not by itself establish that tsc completed its project-wide checks.

[ middle depth ]

The strict family is a moving bundle

strict: true sets the defaults for a documented family of options. Individual members can still be overridden. The family may gain stricter behavior in future TypeScript releases, so teams should read compiler-upgrade errors before reaching for a broad opt-out.

The current family catches these cases:

  • noImplicitAny: reports places where inference would otherwise fall back to any.
  • noImplicitThis: reports a this expression whose implied type is any.
  • strictNullChecks: gives null and undefined distinct types and rejects their use where a concrete value is required.
  • strictPropertyInitialization: reports a declared class property with no initializer and no definite constructor assignment, unless its type permits undefined.
  • strictFunctionTypes: checks parameter compatibility more safely for function syntax. The documented check does not apply the same way to method syntax.
  • strictBindCallApply: checks the arguments passed through a function's call, bind, and apply methods against the underlying function.
  • useUnknownInCatchVariables: types a catch variable as unknown, requiring a check before property access.
  • strictBuiltinIteratorReturn: instantiates the return type of built-in iterators with undefined instead of any.
  • alwaysStrict: parses files in ECMAScript strict mode and emits "use strict" for each source file.

Here are four of those checks in code:

function parse(input) {             // noImplicitAny
  return input.trim();
}

const user = users.find(byId);
user.name;                          // strictNullChecks: possibly undefined

class Session {
  token: string;                    // strictPropertyInitialization
}

try {
  load();
} catch (error) {
  console.log(error.message);       // useUnknownInCatchVariables
}

exactOptionalPropertyTypes and noUncheckedIndexedAccess are useful stricter checks, but the current TSConfig reference does not list them as members enabled by strict. Turn them on explicitly when you want their behavior.

Wrong "Every option with strict or unchecked in its name is covered by strict: true."

Right treat strict as the documented family, and inspect the effective configuration when an exact flag matters.

module models output; moduleResolution models lookup

The module option controls the module format of emitted JavaScript. It also informs checking decisions such as module-format detection, interoperation between formats, and whether syntax including top-level await is available. It still matters under noEmit because TypeScript needs to model what the runtime or emitter will do.

moduleResolution controls how a specifier such as "pkg" or "./util.js" maps to a file that provides type information. The choice should match the resolver used by the runtime or bundler.

For an application whose bundler consumes raw TypeScript, the official guide gives this shape:

{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "bundler",
    "noEmit": true,
    "verbatimModuleSyntax": true
  }
}

Bundler resolution supports behaviors common to bundlers, including extensionless relative paths and package exports and imports. It should not be used to approve source that will later run as unbundled Node.js output.

For code compiled and run by modern Node.js, TypeScript's module guide uses a Node mode:

{
  "compilerOptions": {
    "module": "nodenext",
    "verbatimModuleSyntax": true
  }
}

nodenext implies its matching module resolution. TypeScript then uses file extensions and the nearest package.json type field when it detects the format of ordinary .ts files; .mts and .cts make the source format explicit.

Wrong "moduleResolution: bundler makes TypeScript bundle the program."

Right it models how imports will be found. A separate bundler still performs bundling and JavaScript emission.

Single-file type stripping has a boundary

Other transpilers commonly process one file at a time. They cannot perform transforms that require understanding the full TypeScript type system. isolatedModules asks TypeScript to report constructs that a single-file process cannot interpret safely, including certain type-only exports and references to ambient const enum members.

{
  "compilerOptions": {
    "strict": true,
    "noEmit": true,
    "isolatedModules": true
  }
}

isolatedModules does not change TypeScript's checking or emit behavior. It adds warnings about compatibility with that style of transpilation. Project-wide type checking remains a separate tsc responsibility in this pipeline.

[ senior depth ]

A declaration file is a consumer contract

declaration: true makes TypeScript generate corresponding .d.ts output for TypeScript or JavaScript implementation files in the project. The declaration describes the module's external API so TypeScript-aware consumers can type-check imports and provide editor support.

// src/index.ts
export function parsePort(value: string): number {
  return Number(value);
}
// dist/index.d.ts
export declare function parsePort(value: string): number;

When a bundler or another transpiler owns JavaScript output, TypeScript can produce declarations alone:

{
  "compilerOptions": {
    "declaration": true,
    "emitDeclarationOnly": true,
    "declarationMap": true,
    "outDir": "dist"
  }
}

emitDeclarationOnly suppresses JavaScript output. declarationMap emits maps that supported editors use to navigate from declarations to source. A private application may use noEmit. A library that ships JavaScript needs declarations, generated or handwritten, if TypeScript consumers are to receive its types.

Bundling creates a sharp edge for libraries. The TypeScript guide states that every declaration file represents one JavaScript file. If a bundler collapses several source modules into one JavaScript file while tsc emits several unbundled declarations, preserved imports in those declarations may not match the artifact consumers load. The declaration build must describe the shipped JavaScript layout.

Project references create typed boundaries

The top-level references array connects TypeScript projects:

{
  "files": [],
  "references": [
    { "path": "./packages/core" },
    { "path": "./packages/web" },
    { "path": "./packages/cli" }
  ]
}

The empty files array keeps this solution config from compiling files a second time. Each leaf config owns its source files and environment. A dependent project imports against the referenced project's declaration output rather than treating all dependency source as one undifferentiated program.

Referenced projects must enable composite:

{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "rootDir": "src",
    "outDir": "dist",
    "strict": true
  },
  "include": ["src/**/*.ts"]
}

composite imposes constraints that make outputs discoverable: every implementation file must be matched by include or listed in files; the default rootDir becomes the config directory when it is not set explicitly; and declaration defaults to true.

Run build mode at the graph boundary:

tsc --build
tsc --build --clean

Build mode discovers referenced projects, detects which outputs are out of date, and builds them in dependency order. An ordinary tsc invocation does not automatically build referenced dependencies.

Wrong "A reference is another spelling of include."

Right include selects root files inside one project. A reference connects separate projects through declaration outputs and gives build mode a dependency graph.

One monorepo can contain several runtime models

A single config represents one environment. Browser code, Node.js code, and shared library code can require different global types and module rules. Put shared safety settings in a base config, then make the runtime choice in each leaf:

// packages/web/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "lib": ["ES2022", "DOM"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "declaration": true,
    "emitDeclarationOnly": true,
    "outDir": "dist"
  },
  "references": [{ "path": "../core" }]
}
// packages/cli/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "lib": ["ES2022"],
    "module": "nodenext",
    "outDir": "dist"
  },
  "references": [{ "path": "../core" }]
}

The browser leaf models its bundler and has DOM declarations. Because it participates in the build graph, it emits declarations instead of using noEmit; TypeScript rejects a referenced project that disables emit. The bundler still owns its JavaScript output. The CLI leaf models Node.js module behavior. Project references connect both leaves to core without pretending that all three packages run under the same loader or expose the same globals.

Wrong "If the bundler emitted JavaScript, the code passed TypeScript checking."

Right a type-stripping emitter can produce runnable JavaScript without completing TypeScript's project-wide analysis. Keep tsc --noEmit or a declaration build as an explicit check in the build pipeline.

dig deeper

Primary sources behind these notes - the specs and official docs worth reading in full.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

more TypeScript

was this useful?