The tsconfig.json file

Setting up introduced tsconfig.json, the file that marks the root of a TypeScript project and holds its settings. Running tsc without file names reads it. Its main parts:

  • "compilerOptions": how to check and what to emit.
  • "include" and "exclude": which files belong to the project, as glob patterns such as ["src"].
  • "extends": a path or package name of another configuration to start from. Teams use it for a shared base, and there are public base configurations per runtime (for example the @tsconfig/bases packages).
  • "references": links to other TypeScript projects in the same repository, built in order with tsc -b.

Unlike strict JSON, tsconfig.json may contain comments and trailing commas; tsc --init writes a file full of commented-out options to choose from. A solid configuration for a Node.js service compiled with tsc, as of TypeScript 7:

{
  "compilerOptions": {
    "rootDir": "src",
    "outDir": "dist",
    "module": "nodenext",
    "target": "es2023",
    "lib": ["es2023"],
    "types": ["node"],
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "verbatimModuleSyntax": true,
    "isolatedModules": true,
    "sourceMap": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

The sections below explain the choices. Most of these options are also in what tsc --init generates.

Options that matter

Option What it does
rootDir, outDir where the sources are and where the JavaScript goes. Since TypeScript 6.0, rootDir defaults to the folder of tsconfig.json; set it to "src" to get dist/main.js instead of dist/src/main.js.
module the module system of the output and the import rules: nodenext for Node.js, esnext or preserve with a bundler (see module resolution)
target the JavaScript version to emit. Newer syntax is rewritten for older targets. Choose what your runtime fully supports; es2023 suits Node.js 20 and later.
lib which built-in APIs exist: es2023 for the language, dom for browser APIs. Leaving out dom in Node.js code turns a stray document into an error.
types which global @types packages to load, such as ["node"]; empty by default since 6.0
noEmit check only, write nothing; for projects that a bundler or Node.js type stripping runs
sourceMap, declaration also write .js.map files for debugging, and .d.ts files for a library's users
verbatimModuleSyntax, isolatedModules, erasableSyntaxOnly forbid code that tools working on one file at a time cannot handle (see TypeScript in Node.js and the browser)
skipLibCheck do not type-check .d.ts files of dependencies: faster, and avoids errors in code you cannot fix. Your own code is still fully checked.
noEmitOnError write no JavaScript when there are type errors. The default is false: tsc emits anyway.

Changed defaults and removed options

TypeScript 6.0 (March 2026) changed several defaults and deprecated old options; TypeScript 7.0 (July 2026), the native compiler, keeps the new defaults and turns the deprecations into errors. Configurations copied from older projects and tutorials often stop working. The defaults as of TypeScript 7.0:

Option Default now Before 6.0
strict true false
target the latest stable edition (es2025) es5
module esnext commonjs (with the old default target)
types [] (list global packages yourself) every package in node_modules/@types
rootDir the folder of tsconfig.json computed from the input files
esModuleInterop, alwaysStrict always true; false is an error false by default (alwaysStrict: on only with strict)
noUncheckedSideEffectImports true false

Removed in TypeScript 7.0, with their replacements:

  • target: "es5" and downlevelIteration: the lowest target is now ES2015. Every current browser and Node.js version runs modern JavaScript.
  • moduleResolution: "node" (node10) and "classic" (removed since 6.0): use nodenext or bundler.
  • baseUrl: write paths relative to the project root instead.
  • module: "amd", "umd", "system", "none" and outFile (removed since 6.0): use a bundler.
  • tsc file.ts in a folder with a tsconfig.json is an error (TS5112, since 6.0); add --ignoreConfig if you really want to compile one file with default settings.

An old configuration fails at once, with one error per option:

tsconfig.json(3,15): error TS5108: Option 'target=ES5' has been removed. Please remove it from your configuration.
tsconfig.json(4,25): error TS5108: Option 'moduleResolution=node10' has been removed. Please remove it from your configuration.
tsconfig.json(5,5): error TS5102: Option 'baseUrl' has been removed. Please remove it from your configuration.
  Use '"paths": {"*": ["./*"]}' instead.

Beyond strict

strict is a group of options: noImplicitAny, strictNullChecks, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, noImplicitThis, useUnknownInCatchVariables, alwaysStrict and strictBuiltinIteratorReturn. New versions may add members, so a TypeScript upgrade can report new errors in strict projects. noImplicitAny, for example, refuses parameters without a type:

function totalKWh(readings) {
  return readings.reduce((sum, r) => sum + r.kWh, 0);
}
example.ts(1,19): error TS7006: Parameter 'readings' implicitly has an 'any' type.

Some useful checks are not part of strict, because they would break too much existing code. The most valuable one is noUncheckedIndexedAccess. Without it, reading an array element or an index signature is assumed to find a value:

const readings: number[] = []; // no readings for this meter yet

const latest = readings[readings.length - 1]; // type: number
console.log("Latest reading:");
console.log(latest.toFixed(1));
Latest reading:
TypeError: Cannot read properties of undefined (reading 'toFixed')

With the option on, the same file does not compile:

latest.ts(5,13): error TS18048: 'latest' is possibly 'undefined'.

and the fix is to handle the empty case, which the compiler then accepts:

const readings: number[] = [];

const latest = readings.at(-1); // type: number | undefined
console.log(latest === undefined ? "No readings yet" : `${latest.toFixed(1)} kWh`);
No readings yet

Similarly, noFallthroughCasesInSwitch catches a forgotten break. This code compiles under strict, and the daytime tariff is silently overwritten:

function tariffFor(hour: number): string {
  let tariff = "unknown";
  switch (true) {
    case hour >= 7 && hour < 23:
      tariff = "day";
    default:
      tariff = "night";
  }
  return tariff;
}

console.log(tariffFor(14), tariffFor(2));
night night
tariff.ts(4,5): error TS7029: Fallthrough case in switch.
Option Catches
noUncheckedIndexedAccess array and index-signature reads that may be undefined
exactOptionalPropertyTypes the difference between a missing property and one set to undefined (see null and undefined)
noImplicitOverride methods that replace a parent's method without the override keyword (see classes)
noFallthroughCasesInSwitch a case that runs into the next one
noImplicitReturns functions where some paths return a value and others not
noUnusedLocals, noUnusedParameters dead code; many teams leave this to the linter instead, where it is a warning rather than a build failure

tsc --init switches on the first two. In an existing project, turn the flags on one at a time, each in its own commit.

Linting with typescript-eslint

The type checker finds type errors; a linter finds code that is valid but probably wrong. ESLint cannot read TypeScript syntax by itself. typescript-eslint is the project that makes it possible: a parser that lets ESLint read TypeScript, and a plugin with rules written for TypeScript, some of which use the type checker's knowledge. It is at version 8 as of October 2026. Install it next to ESLint:

npm install --save-dev eslint @eslint/js typescript-eslint

The configuration is an ESLint flat config file. With recommendedTypeChecked and the project service, rules can ask the compiler for the type of an expression:

// eslint.config.mjs
import eslint from "@eslint/js";
import { defineConfig } from "eslint/config";
import tseslint from "typescript-eslint";

export default defineConfig(
  eslint.configs.recommended,
  tseslint.configs.recommendedTypeChecked,
  {
    languageOptions: {
      parserOptions: {
        projectService: true,
        tsconfigRootDir: import.meta.dirname,
      },
    },
  },
);
npx eslint src
/home/ravi/billing/src/import.ts
   7:3   error  Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator  @typescript-eslint/no-floating-promises
  12:21  error  Unexpected any. Specify a different type  @typescript-eslint/no-explicit-any

✖ 2 problems (2 errors, 0 warnings)
  • no-floating-promises finds the unawaited calls that the compiler accepts (see forgotten await). Rules like this need type information, which is why typed linting is slower than plain linting.
  • no-explicit-any keeps any from creeping back in; use unknown instead.
  • Start with recommended (no type information, fast) if typed linting is too slow for a large project, and add strict or stylistic configurations later.
{
  "devDependencies": {
    "@typescript/native": "npm:typescript@^7.0.2",
    "typescript": "npm:@typescript/typescript6@^6.0.2"
  }
}

The older linter TSLint is deprecated and its repository archived since 2021; configurations with a tslint.json need to move to typescript-eslint. Newer, faster linters such as Biome and oxlint also understand TypeScript, mostly without type-aware rules. Leave formatting to a code formatter such as Prettier, which handles .ts files out of the box.

Checking JavaScript files

The same checker can read plain JavaScript, using JSDoc comments for types and inference for the rest. Three switches control it.

// @ts-check is a comment at the top of a JavaScript file that asks the TypeScript checker (in the editor, or tsc with allowJs) to type-check that one file. // @ts-nocheck does the opposite. The compiler option allowJs lets .js files be part of a TypeScript project: they can import from .ts files and the other way around, and tsc copies them to the output. The option checkJs type-checks every JavaScript file in the project, as if each had // @ts-check.

A JavaScript project with two files and this configuration:

{
  "compilerOptions": {
    "module": "nodenext",
    "allowJs": true,
    "checkJs": false,
    "noEmit": true,
    "types": ["node"]
  },
  "include": ["src"]
}
// src/tariffs.js
// @ts-check

/**
 * Cost of a reading in euros.
 * @param {number} kWh
 * @param {number} pricePerKWh
 * @returns {number}
 */
export function cost(kWh, pricePerKWh) {
  return kWh * pricePerKWh;
}

console.log(cost("4.2", 0.32));
// src/report.js
export function header(meterId) {
  return "Report for " + meterId.toUpperCase();
}

Only the file with // @ts-check is checked. With "checkJs": true, the other file is checked as well, and strict (the default) asks for a type on its parameter:

src/report.js(2,24): error TS7006: Parameter 'meterId' implicitly has an 'any' type.
src/tariffs.js(14,18): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.

Suppressing an error: @ts-expect-error

Sometimes you know an error is there and cannot fix it yet. A comment on the line before switches the error off. There are two such comments, and one is clearly better. // @ts-expect-error suppresses the type error on the next line, and is itself an error when that line has no error. It documents a known problem and reminds you to remove it once the problem is fixed:

interface MeterReading {
  meterId: string;
  kWh: number;
}

// TODO(INC-30420): the legacy importer still sends kWh as a string.
// @ts-expect-error: remove when the importer is fixed
const fromImporter: MeterReading = { meterId: "E-1001", kWh: "4.2" };

console.log(typeof fromImporter.kWh);
string

The comment worked, and the output shows what it hid: a string where a number is promised. When the importer is fixed and the line becomes correct, the comment fails the build:

interface MeterReading {
  meterId: string;
  kWh: number;
}

// @ts-expect-error: the legacy importer sends kWh as a string
const fromImporter: MeterReading = { meterId: "E-1001", kWh: 4.2 };

console.log(typeof fromImporter.kWh);
example.ts(6,1): error TS2578: Unused '@ts-expect-error' directive.

// @ts-ignore suppresses the next line's errors too, but stays silent forever, also after the problem is gone or when a new, different error appears on that line. Prefer @ts-expect-error, always with a reason and a ticket number, and let the linter rule @typescript-eslint/ban-ts-comment enforce that.

Migrating a JavaScript project

Incremental migration is the practice of moving a JavaScript code base to TypeScript in small steps, file by file and flag by flag, while the application keeps running and shipping, instead of rewriting it in one go. Because .js and .ts files can live in the same project, there is never a moment where everything is broken. A route that works for most projects:

Five steps of an incremental migration: one, add TypeScript with allowJs and a type check in CI; two, check JavaScript with @ts-check per file, then checkJs; three, rename files to .ts one at a time, leaves first; four, turn on stricter flags one at a time; five, remove remaining any and @ts-expect-error. 1 allowJs + CI check 2 @ts-check then checkJs 3 .js → .ts leaves first 4 stricter flags one at a time 5 clean up any, expect-error
Each step is small enough to review and to release on its own. The CI check from step 1 keeps finished parts from sliding back.
  1. Add TypeScript without changing code. Install typescript and the needed @types packages, add a tsconfig.json with allowJs, and run tsc --noEmit in the pipeline from day one. Nothing is checked yet, but the build path exists.
  2. Check JavaScript. Add // @ts-check to files one by one and fix what it finds, often with JSDoc types. When most files pass, switch on checkJs and add // @ts-nocheck to the few that are not ready.
  3. Rename files to .ts. Start with leaf modules that import nothing of your own (helpers, data models), then work toward the entry points. Replace JSDoc with annotations and interfaces as you go. Each renamed file benefits everything that imports it.
  4. Tighten the options from beyond strict, one per change. If the existing code produces too many errors for one go, leave // @ts-expect-error comments with a ticket number and fix them in later work.
  5. Clean up. Search for any, as casts at trust boundaries and remaining suppression comments, and replace them with real types and run-time checks.

A finished step looks like this: the former meter-report.js, renamed and typed. The behavior is unchanged, but every caller is now checked:

// meter-report.ts: the former meter-report.js, now with types
interface MeterReading {
  meterId: string;
  kWh: number;
  estimated?: boolean;
}

function totalKWh(readings: readonly MeterReading[]): number {
  return readings.reduce((sum, r) => sum + r.kWh, 0);
}

function summary(readings: readonly MeterReading[]): string {
  const estimated = readings.filter((r) => r.estimated).length;
  return `${readings.length} readings, ${totalKWh(readings).toFixed(1)} kWh, ${estimated} estimated`;
}

console.log(summary([
  { meterId: "E-1001", kWh: 4.2 },
  { meterId: "E-1002", kWh: 3.1, estimated: true },
]));
2 readings, 7.3 kWh, 1 estimated

Putting the checks in the pipeline

A type check that only runs on some developers' machines is optional in practice. Put it in package.json scripts next to the linter and the tests, so that everyone and the pipeline run the same commands:

{
  "scripts": {
    "build": "tsc",
    "typecheck": "tsc --noEmit",
    "lint": "eslint src",
    "test": "node --test"
  }
}

and run them on every push and pull request, so that a type error blocks the merge (see testing and quality in pipelines). A minimal GitHub Actions workflow:

name: checks
on: [push, pull_request]

jobs:
  checks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm run typecheck
      - run: npm run lint
      - run: npm test

TypeScript 7's compiler is fast enough that a full type check of a medium-sized project takes seconds, so there is little reason to skip it. On a machine with few cores or little memory, the new options --checkers (experimental; the number of parallel type-checking workers, 4 by default) and --singleThreaded limit how much it uses at once.