tsconfig, and Moving JavaScript Across
The flags that change how much the compiler helps you, and how to bring an existing JavaScript project over without stopping work for a month.
By the end: Configure a project deliberately and migrate JavaScript incrementally.
Two practical matters to finish with. Which compiler settings actually change your life, and how to move an existing JavaScript project across without stopping work for a month.
There are well over a hundred compiler options. About twelve of them matter, and the rest you can look up when you need them.
The settings this course is taught under
// This is the real configuration, not an illustration. Every sample in this
// course is checked with exactly these options.
//
// {
// "compilerOptions": {
// "strict": true,
// "target": "es2022",
// "module": "esnext",
// "moduleResolution": "bundler",
// "moduleDetection": "force",
// "lib": ["es2023", "dom"],
//
// "noUncheckedIndexedAccess": true,
// "exactOptionalPropertyTypes": true,
// "noImplicitOverride": true,
// "noFallthroughCasesInSwitch": true,
//
// "skipLibCheck": true
// }
// }
//
// The first block is the baseline any project should have. The second is the
// four extras worth arguing for. skipLibCheck is a build-speed concession.
export {};
strict, and what it contains
"strict": true is one switch that turns on about eight checks. It is the single most consequential line in the file, and the correct value is true.
// strictNullChecks — lesson 08. null and undefined become real types.
function initial(name: string | null): string {
return name === null ? '?' : name[0]!;
}
// noImplicitAny — lesson 01. Parameters must be annotated.
function double(n: number): number {
return n * 2;
}
// strictFunctionTypes — lesson 06. Parameters checked contravariantly.
type Handler = (value: string) => void;
const handler: Handler = (value) => console.log(value.length);
// strictPropertyInitialization — class fields must be assigned.
class Account {
balance: number;
constructor(initial: number) {
this.balance = initial;
}
}
// strictBindCallApply — bind, call and apply are type-checked.
const bound = double.bind(null, 21);
// useUnknownInCatchVariables — lesson 12. A caught value is unknown.
function attempt(): string {
try {
throw new Error('x');
} catch (error) {
return error instanceof Error ? error.message : 'unknown';
}
}
// alwaysStrict — the emitted code is in strict mode.
console.log(initial('Ada'), initial(null));
console.log(handler('text') ?? 'void', bound());
console.log(new Account(100).balance, attempt());
Output
class Account {
// Declared and never assigned. In JavaScript this is silently undefined.
balance: number;
constructor() {}
}
console.log(new Account().balance);
What the compiler says
error TS2564: Property 'balance' has no initializer and is not definitely assigned in the constructor.
The four worth adding on top
noUncheckedIndexedAccess
Covered in lesson 07. Makes arr[i] return T | undefined, because it might. This is the one people find most irritating and it catches genuine bugs, particularly around empty arrays and lookup tables.
const values: number[] = [1, 2, 3];
const first: number = values[0];
console.log(first);
What the compiler says
error TS2322: Type 'number | undefined' is not assignable to type 'number'.
exactOptionalPropertyTypes
Covered in lesson 08. Distinguishes “absent” from “present and undefined”, which matters for anything that serialises. The cost is that object spreads sometimes need more care.
interface Options {
label?: string;
}
const options: Options = { label: undefined };
console.log(options);
What the compiler says
error TS2375: Type '{ label: undefined; }' is not assignable to type 'Options' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
noImplicitOverride
Covered in lesson 11. Requires the override keyword, which turns a renamed base method from silent dead code into a compile error. Almost no downside.
noFallthroughCasesInSwitch
function describe(n: number): string {
switch (n) {
case 1:
return 'one';
case 2:
return 'two';
default:
return 'many';
}
}
console.log(describe(1), describe(2), describe(9));
Output
function describe(n: number): string {
let result = '';
switch (n) {
case 1:
result = 'one';
// No break, so this falls into case 2. Almost always a mistake.
case 2:
result = 'two';
break;
default:
result = 'many';
}
return result;
}
console.log(describe(1));
What the compiler says
error TS7029: Fallthrough case in switch.
target, module and lib
Three settings that get confused constantly, because all three sound like they are about the same thing.
targetdecides what syntax is emitted. Set it toes2022or later unless you must support something ancient. A low target means the compiler rewritesasync, class fields and optional chaining into older equivalents, producing far more code than you wrote.moduledecides what import syntax is emitted.esnextorpreserveif a bundler will handle it;nodenextfor Node.libdecides which built-ins the compiler believes exist. It ships no code, so alibahead of your runtime turns a compile-time error into a runtime one.
// This course targets es2022, so all of this survives essentially as written:
// class fields, private fields, optional chaining, nullish coalescing, and
// async/await.
class Counter {
#count = 0;
step = 1;
bump(): number {
this.#count += this.step;
return this.#count;
}
}
async function main(): Promise<string> {
const counter = new Counter();
counter.bump();
counter.bump();
const label: string | undefined = undefined;
return `${label ?? 'count'}: ${counter.bump()}`;
}
console.log(await main());
What this compiles to
// This course targets es2022, so all of this survives essentially as written:
// class fields, private fields, optional chaining, nullish coalescing, and
// async/await.
class Counter {
#count = 0;
step = 1;
bump() {
this.#count += this.step;
return this.#count;
}
}
async function main() {
const counter = new Counter();
counter.bump();
counter.bump();
const label = undefined;
return `${label ?? 'count'}: ${counter.bump()}`;
}
console.log(await main());
Output
moduleResolution, and the import extension question
The setting behind the most common “works in my editor, fails at run time” problem.
bundler— extensions optional. Correct when a bundler or a framework processes your code. This course uses it.nodenext— extensions required, and you write./types.jsto reachtypes.ts. Correct for code Node runs directly.node10— the legacy algorithm. Only for old projects you have not migrated.
If imports resolve in your editor and fail when you run the output, you have bundler resolution and no bundler. That is the whole diagnosis.
Where the errors come from: noEmit and the build
Worth being explicit about who does what, because in a modern setup the compiler usually is not producing your JavaScript.
// tsc --noEmit
// Check everything, write nothing. This is your CI gate and what your
// editor is doing continuously. The command you will use most.
//
// tsc
// Check and emit JavaScript. Used when the compiler IS your build, which is
// typical for a published library.
//
// tsc --watch --noEmit
// The same check, incremental, on file save. Useful in a second terminal.
//
// Everything else — bundlers, and Node's own type stripping — erases types
// without checking them. That division is deliberate: stripping is fast and
// mechanical, checking is slow and thorough. Do both, at different times.
export {};
Migrating JavaScript
The realistic version. The goal is a codebase that compiles at every point along the way, never a branch that has been broken for three weeks.
Step one: compile the JavaScript you already have
// {
// "compilerOptions": {
// "allowJs": true, // .js files are part of the project
// "checkJs": false, // but do not check them yet
// "strict": true, // on from the start, for the .ts files you add
// "target": "es2022",
// "module": "preserve",
// "moduleResolution": "bundler",
// "outDir": "dist"
// },
// "include": ["src/**/*"]
// }
//
// At this point `tsc --noEmit` passes trivially, because nothing is checked.
// That is fine — you now have the harness. Commit it.
export {};
Step two: turn checking on for JavaScript, one file at a time
// At the top of a single .js file:
//
// // @ts-check
//
// The compiler now checks that file with your strict settings, using JSDoc for
// the types it cannot infer:
//
// /**
// * @param {string} name
// * @param {number} [times]
// * @returns {string}
// */
// function repeat(name, times = 1) {
// return name.repeat(times);
// }
//
// This is a real intermediate step, not a stunt. You get genuine checking with
// zero renames and no build changes, which means you can do it to one file
// during an afternoon and ship it.
export {};
Step three: rename, leaf-first
Rename .js to .ts starting with files that import nothing from your own code. Utilities and constants first, entry points last. A leaf file has no dependencies to be wrong about, so its types are quick to get right, and every leaf you convert makes the next layer easier.
Expect the middle of the migration to be the worst part. Files that import untyped modules will need any in places, and that is acceptable as long as it is temporary and visible.
Step four: the escape hatch, used deliberately
function strict(value: number): string {
return String(value);
}
// @ts-expect-error passing a string here is deliberate, to show suppression
const wrong = strict('12.5');
console.log('suppressed, and it happens to work at run time:', wrong);
// The important difference: if the error goes away, @ts-expect-error itself
// becomes an error, so it cannot outlive the problem it was suppressing.
// @ts-ignore would silently do nothing forever.
console.log('the suppression is self-cleaning');
Output
function fine(value: number): string {
return value.toFixed(2);
}
// @ts-expect-error
const ok = fine(12.5);
console.log(ok);
What the compiler says
error TS2578: Unused '@ts-expect-error' directive.
Step five: tighten
// Once the codebase is .ts and compiling:
//
// 1. checkJs: true — for any .js files that remain
// 2. noUncheckedIndexedAccess — expect a burst of errors around arr[i]
// 3. exactOptionalPropertyTypes — expect errors around object spreads
// 4. noImplicitOverride — mechanical, mostly automated by an editor
// 5. verbatimModuleSyntax — forces explicit `import type`
//
// One flag per pull request. Each one is a bounded, reviewable change, and if
// one turns out to be too disruptive you can drop it without unpicking the
// others.
export {};
A configuration for a new project
// For an application built with a bundler or a framework:
//
// {
// "compilerOptions": {
// "strict": true,
// "target": "es2022",
// "lib": ["es2023", "dom", "dom.iterable"],
// "module": "preserve",
// "moduleResolution": "bundler",
// "moduleDetection": "force",
// "noEmit": true,
//
// "noUncheckedIndexedAccess": true,
// "exactOptionalPropertyTypes": true,
// "noImplicitOverride": true,
// "noFallthroughCasesInSwitch": true,
// "verbatimModuleSyntax": true,
//
// "skipLibCheck": true,
// "isolatedModules": true,
// "resolveJsonModule": true
// },
// "include": ["src"]
// }
//
// For code Node runs directly, change module and moduleResolution to
// "nodenext" and write .js extensions in your imports.
//
// For a published library, drop noEmit and add:
// "declaration": true, "declarationMap": true, "sourceMap": true,
// "outDir": "dist", "rootDir": "src"
export {};
Where to go from here
You now have the whole language that matters. The remaining growth is mostly practice, plus three specific things worth doing.
- Turn
tsc --noEmitinto a CI gate on something you own. Everything in this course is worth less without it, because nothing else will tell you the types have drifted. - Read the type definitions of a library you use. Open the
.d.tsfor something small and work out how it does what it does. This is the fastest way to get comfortable with the material in lesson 17, and it is much easier than writing it. - Convert one real file from JavaScript, with strict settings on, and notice what the compiler finds. In my experience it is always something.
The error catalogue is the page to keep open while you work. The DSA and OOP pages take the code from the other two courses and type it, which is the most direct way to see what changes in code you already understand.
Your turn
- Create a
tsconfig.jsonwith only"strict": trueand runtsc --noEmiton a small file. Then addnoUncheckedIndexedAccessand count the new errors. - Set
targettoes5on a file containing a class with a#privatefield and anasyncfunction. Look at the emitted JavaScript, then set it back. - Take a plain
.jsfile, add// @ts-checkat the top, and fix whatever appears. Add JSDoc annotations for anything the compiler cannot infer. - Write a deliberate type error and suppress it with
@ts-expect-error. Then fix the underlying error and confirm the comment itself now fails. - Set
libto["es5"]and call[1,2,3].at(-1). Note that the error is at compile time. Then reason about what would happen in the reverse situation. - Add
"noEmit": trueand runtsc. Confirm nothing is written and errors are still reported.
Recap
"strict": trueis the most important line in the file, and it is eight checks in one. Never disable one of them to silence an error; suppress per line instead.- Four flags are worth adding on top:
noUncheckedIndexedAccess,exactOptionalPropertyTypes,noImplicitOverrideandnoFallthroughCasesInSwitch. targetcontrols emitted syntax,modulecontrols emitted imports,libcontrols what the compiler believes exists. Only the first two produce code.- Most bundlers strip types without checking them, so
tsc --noEmitin CI is what actually protects you. - Migrate with
allowJsfirst, then// @ts-checkper file with JSDoc, then rename leaf-first, then one extra flag per pull request. - Use
@ts-expect-errorrather than@ts-ignore: it fails once the underlying problem is fixed, so your suppressions cannot outlive their reasons.