Skip to content
academia.sh

Lesson 21 / 23

Working With JavaScript

Bringing JavaScript files into the project and checking them, typing with comments, file-level check directives, and the gradual migration strategy.

Contents

The previous lesson built the way to type an untyped library from outside. The project’s own JavaScript files are a different problem: an existing codebase cannot be converted overnight.

TypeScript is designed to support this migration. Every JavaScript file is at the same time a valid TypeScript input; the level of checking can be set per project and per file. This lesson covers the tools and order of that migration.

Bringing JavaScript Files Into the Project

There are two separate options, and they are frequently confused.

allowJs lets JavaScript files into the project: the compiler reads them, knows the names they export, and allows them to be imported from TypeScript files. checkJs makes those files go through type checking.

{
  "compilerOptions": {
    "target": "es2022",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "strict": true,
    "allowJs": true,
    "checkJs": false,
    "noEmit": true
  },
  "include": ["src"]
}

Let there be a package.json at the project root with content { "name": "measurement-migration", "type": "module", "version": "1.0.0" }. src/legacy.js is pre-migration code:

export function average(records) {
  let total = 0;
  for (const r of records) {
    total += r.value;
  }
  return total / records.length;
}

src/new.ts uses it:

import { average } from "./legacy.js";

const records = [{ value: 21.4 }, { value: 22.1 }];
const result: number = average(records);
console.log(result.toFixed(1));

With this configuration, compilation passes without error. But there is no guarantee: the average function’s parameter and return value count as any, so the result declaration checks nothing.

With "checkJs": true:

src/legacy.js(1,25): error TS7006: Parameter 'records' implicitly has an 'any' type.

Strict configuration now applies to the JavaScript file too. In a large codebase this step produces hundreds of diagnostics; migration is therefore usually done file by file.

Typing With Comments

JavaScript files can be typed without changing their syntax. The compiler reads comment blocks in a specific form as type declarations:

/**
 * @typedef {object} Measurement
 * @property {string} id
 * @property {number} value
 */

/**
 * @param {readonly Measurement[]} records
 * @returns {number}
 */
export function average(records) {
  let total = 0;
  for (const r of records) {
    total += r.value;
  }
  return total / records.length;
}

This file checks without error under "checkJs": true, and the function it exports is now typed. The TypeScript file using it:

import { average } from "./legacy.js";

const records = [
  { id: "s-01", value: 21.4 },
  { id: "s-02", value: 22.1 },
];
const result: number = average(records);
console.log(result.toFixed(1));

average([{ id: "s-03", value: "23.0" }]);
src/new.ts(10,24): error TS2322: Type 'string' is not assignable to type 'number'.

The diagnostic shows that the contract coming from the comment in the JavaScript file is genuinely enforced. The @typedef, @param, and @returns directives are the comment-form counterpart of the type notations built in the course; union, array, and generic notations are supported too.

The cost of this method is readability: the same information is written longer and sits apart from the body. Its gain is that the file is still an ordinary JavaScript file — it runs without needing a build step. It is a suitable middle ground in projects with no build step, or in migration’s early stage.

File-Level Check Directives

Checking can also be turned on and off with a comment placed at the top of a file. While "checkJs": false, writing // @ts-check in a file brings only that file under checking:

// @ts-check

/**
 * @typedef {object} Measurement
 * @property {string} id
 * @property {number} value
 */

/**
 * @param {readonly Measurement[]} records
 * @returns {number}
 */
export function average(records) {
  let total = 0;
  for (const r of records) {
    total += r.id;
  }
  return total / records.length;
}
src/legacy.js(16,5): error TS2322: Type 'string' is not assignable to type 'number'.

The reverse directive is // @ts-nocheck, and it excludes a file from checking while "checkJs": true.

The two directives are used at different points. // @ts-check is for bringing a small number of files under checking at the start of a migration — the project setting is off, files are turned on one by one. // @ts-nocheck is for temporarily leaving out the few files remaining at the end of a migration — the project setting is on, files are exempted one by one. The second arrangement is preferred: the default is safe, exemptions are visible.

Single-Line Exemption

Sometimes not a whole file but a single line is the problem. There are two directives and the difference between them matters.

// @ts-ignore suppresses all diagnostics on the next line and stays silent even if there is nothing to suppress. // @ts-expect-error, instead, says it expects a diagnostic on the next line; if the expected diagnostic does not appear, it produces a diagnostic of its own:

import { average } from "./legacy.js";

const records = [
  { id: "s-01", value: 21.4 },
  { id: "s-02", value: 22.1 },
];
const result: number = average(records);
console.log(result.toFixed(1));

// @ts-expect-error string value is not accepted
average([{ id: "s-03", value: "23.0" }]);

// @ts-expect-error this line is actually valid
average([{ id: "s-04", value: 24.0 }]);
src/new.ts(13,1): error TS2578: Unused '@ts-expect-error' directive.

The directive on the tenth line is correct and produces no diagnostic; the directive on the thirteenth line is unnecessary and gets reported.

This behavior makes // @ts-expect-error superior to // @ts-ignore. When a suppressed error is later fixed — the library gets updated, the type definition improves — @ts-ignore stays silent and no one notices. @ts-expect-error instead says “I am no longer needed.” Exemptions clean themselves up this way.

A reason is written next to both directives; as in the example above, the rest of the directive’s text is treated as a comment.

Gradual Migration Order

The tools gathered can be put into a sequence. Each step preserves the guarantee the previous step provided:

  1. allowJs is turned on, checkJs stays off. JavaScript files enter the project; no new diagnostic appears. Newly written code is TypeScript.
  2. Contracts for frequently used modules are written. Either with a .d.ts file or with comments. The most-imported modules are handled first; each one spreads a guarantee to every file that uses it.
  3. Files are brought under checking one by one with // @ts-check. Diagnostics that appear are fixed. Ones that cannot be fixed are justified with // @ts-expect-error.
  4. checkJs is turned on, remaining files are exempted with // @ts-nocheck. The default is now on the safe side.
  5. Files are moved to the .ts extension and comment types are converted to syntax.
  6. Strictness flags outside the umbrella are turned on one by one. The noUncheckedIndexedAccess and exactOptionalPropertyTypes options from the Compiler Configuration lesson come at this stage.

The principle behind the order is keeping red at zero at every step: the build should always pass clean, new diagnostics should not be allowed to accumulate. Otherwise the diagnostic list turns into noise and real errors get lost in it.

At some point any becomes unavoidable. The rule from the escape hatches lesson applies here: any is a transition marker. Where it remains is documented and narrowed in later rounds of the migration.

Summary

  • allowJs brings JavaScript files into the project, checkJs puts them through type checking; the two are separate decisions.
  • Comment notation (@typedef, @param, @returns) types JavaScript files without changing their syntax; the contract is enforced on the TypeScript side too.
  • // @ts-check and // @ts-nocheck turn checking on and off at the file level; the arrangement where exemption is visible is preferred.
  • // @ts-expect-error produces TS2578 when the expected diagnostic does not appear, cleaning up unneeded exemptions; // @ts-ignore does not do this.
  • Gradual migration proceeds by keeping the build clean at every step: bringing files in, writing contracts, turning on checking, moving the extension, raising strictness.

Next Step

The import line in this lesson wrote ./legacy.js, and the extension was required. The same notation does not change even when the source is a TypeScript file — this is one of module resolution’s rules and is surprising at first. The next lesson covers how the compiler turns a module name into a file, path mapping, and the effect of resolution settings on output.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close