Emmanuel Akinfulubi

Lesson 18 of 1819 min read

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

The tsconfig behind every panel on this site

// 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.

What each strict flag actually catches

// 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


      

strictPropertyInitialization, which is easy to trip over

TS2564
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.

Not in strict, and worth having

TS2322
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.

An optional property is not the same as one holding undefined

TS2375
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

Catching a missing break

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


      

A case that falls through into the next

TS7029
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.

target decides how much the compiler rewrites

// 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.

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.

The three commands, and what each is for

// 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

A starting configuration that changes nothing

// {
//   "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

Opt in per file, rather than all at once

// 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

ts-expect-error, and why it beats ts-ignore

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


      

An expect-error with nothing to suppress

TS2578
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

The order to turn the remaining flags on

// 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

What to actually write on day one

// 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.

  1. Turn tsc --noEmit into 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.
  2. Read the type definitions of a library you use. Open the .d.ts for 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.
  3. 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

  1. Create a tsconfig.json with only "strict": true and run tsc --noEmit on a small file. Then add noUncheckedIndexedAccess and count the new errors.
  2. Set target to es5 on a file containing a class with a #private field and an async function. Look at the emitted JavaScript, then set it back.
  3. Take a plain .js file, add // @ts-check at the top, and fix whatever appears. Add JSDoc annotations for anything the compiler cannot infer.
  4. Write a deliberate type error and suppress it with @ts-expect-error. Then fix the underlying error and confirm the comment itself now fails.
  5. Set lib to ["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.
  6. Add "noEmit": true and run tsc. Confirm nothing is written and errors are still reported.

Recap

  • "strict": true is 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, noImplicitOverride and noFallthroughCasesInSwitch.
  • target controls emitted syntax, module controls emitted imports, lib controls what the compiler believes exists. Only the first two produce code.
  • Most bundlers strip types without checking them, so tsc --noEmit in CI is what actually protects you.
  • Migrate with allowJs first, then // @ts-check per file with JSDoc, then rename leaf-first, then one extra flag per pull request.
  • Use @ts-expect-error rather than @ts-ignore: it fails once the underlying problem is fixed, so your suppressions cannot outlive their reasons.

Saved in this browser only.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.