Tooling, Configuration and Migrating from JavaScript
The tsconfig.json options that matter, linting with typescript-eslint, checking JavaScript with checkJs, and moving an existing JavaScript project to TypeScript step by step.
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/basespackages). -
"references": links to other TypeScript projects in the same repository, built in order withtsc -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"anddownlevelIteration: 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): usenodenextorbundler. -
baseUrl: writepathsrelative to the project root instead. -
module: "amd","umd","system","none"andoutFile(removed since 6.0): use a bundler. -
tsc file.tsin a folder with atsconfig.jsonis an error (TS5112, since 6.0); add--ignoreConfigif 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-promisesfinds 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-anykeepsanyfrom creeping back in; useunknowninstead. -
Start with
recommended(no type information, fast) if typed linting is too slow for a large project, and addstrictorstylisticconfigurations 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:
-
Add TypeScript without changing code. Install
typescriptand the needed@typespackages, add atsconfig.jsonwithallowJs, and runtsc --noEmitin the pipeline from day one. Nothing is checked yet, but the build path exists. -
Check JavaScript. Add
// @ts-checkto files one by one and fix what it finds, often with JSDoc types. When most files pass, switch oncheckJsand add// @ts-nocheckto the few that are not ready. -
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. -
Tighten the options from
beyond strict, one per change. If
the existing code produces too many errors for one go, leave
// @ts-expect-errorcomments with a ticket number and fix them in later work. -
Clean up. Search for
any,ascasts 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.