Mert Özen Further With Every Line
Frontend TypeScript Advanced — #11

Migrating from JavaScript to TypeScript: checkJs, JSDoc and Gradual strict

Mert Özen Nov 3, 2026 8 min 3 views
Migrating from JavaScript to TypeScript: checkJs, JSDoc and Gradual strict

We walk through migrating a JavaScript project to TypeScript gradually: allowJs, checkJs and // @ts-check, writing types with JSDoc, the JSDoc changes in TypeScript 7, gradual strict and @ts-expect-error.

Version note: The code in this post targets TypeScript 7.0 and Node 26.

Predict First

This isn't a TypeScript file; it's a plain cart.js. The only differences are the comment line at the top and the JSDoc block above the function:

// @ts-check

/**
 * @param {number} price
 * @param {number} quantity
 * @returns {number}
 */
function lineTotal(price, quantity) {
  return price * quantity;
}

const input = document.querySelector("input");
const total = lineTotal(input.value, 2);
console.log(total.toFixed(2));

If you hand this file to tsc, do you get errors? If so, how many, and on which line? A hint: there isn't a single piece of TypeScript syntax in the file. We'll answer once we've seen how JavaScript files get checked.

Why Migrate Gradually?

You have a JavaScript project with hundreds of files that has been running for years, and you want to move to TypeScript. The first idea is to rename every file to .ts and fix the errors. You can do that in a small project, but in a real one you'll face thousands of errors and the project stays half-done for weeks. Meanwhile the rest of the team keeps shipping features, and you keep resolving conflicts.

TypeScript being a superset helps us here. JavaScript and TypeScript files can live side by side in the same project and import each other. You can turn on type checking file by file and tighten the strict settings one at a time. The application keeps working at every step. In this post we'll walk through the steps of that path in order.

Step One: allowJs and checkJs

By default, tsc doesn't touch .js files. If you compile the opening file without any settings, you get TS6504 and the compiler suggests turning on allowJs. The first tsconfig of a migration looks something like this:

{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": false,
    "noEmit": true,
    "strict": true,
    "noImplicitAny": false
  },
  "include": ["src"]
}

allowJs makes JavaScript files part of the project: your .ts files can import them, and TypeScript infers their types from the code. checkJs decides whether errors are reported in JavaScript files too. We keep it off at first; otherwise you'd see hundreds of errors on day one. To opt individual files into checking, you write // @ts-check at the top of the file. Conversely, once you turn checkJs on later, you can leave out a file that isn't ready yet with // @ts-nocheck. We'll come back to the strict and noImplicitAny lines shortly.

When we tried this small project with npx tsc -p tsconfig.json, the JavaScript file without // @ts-check produced not a single warning, even though it contained an obvious bug; the wrong argument in the file with // @ts-check was caught with TS2345.

Now the answer to the opening question. When we check it with --allowJs, line 13 has two errors. The first is TS18047, "'input' is possibly 'null'.": querySelector returns null if it finds no matching element, and strict mode knows that. The second is TS2345: input.value is a string, but in the JSDoc we said price is a number. Without writing any TypeScript syntax, using comments alone, we caught the very same bug we saw at the start of the beginner series.

Writing Types with JSDoc

JSDoc is a documentation format that has been used in the JavaScript world for a long time. TypeScript reads these comments and uses them as type information. So you can write fairly rich types without converting a file to .ts:

// @ts-check
/** @import { Product } from "./types.ts" */

/**
 * @typedef {object} CartItem
 * @property {Product} product
 * @property {number} quantity
 */

/** @type {CartItem[]} */
const items = [];

/**
 * @param {Product} product
 * @param {number} [quantity]
 */
export function addToCart(product, quantity = 1) {
  items.push({ product, quantity });
}

/**
 * @template T
 * @param {T[]} list
 * @returns {T | undefined}
 */
export function last(list) {
  return list[list.length - 1];
}

addToCart({ id: "p1", name: "Mechanical Keyboard", price: 120 });
const lastItem = last(items);
console.log(lastItem?.product.name);
addToCart({ id: "p2", name: "Mouse" }); // Error

Here types.ts is a small TypeScript file that exports a Product interface with id, name and price. Let's go through the pieces. @import, which came with TS 5.5, brings a type into JSDoc without importing anything at runtime; it's the JSDoc counterpart of import type. @typedef and @property define an object type, much like writing an interface. @type adds an annotation to a variable. The square brackets in [quantity] make the parameter optional. And @template is the JSDoc name for generics: the return type of last(items) is inferred as CartItem | undefined.

On the last line price is missing, and tsc catches it with TS2741, "Property 'price' is missing in type '{ id: string; name: string; }' but required in type 'Product'." JSDoc has a side benefit as well: the types you write double as documentation. When you hover over a function, the editor shows these descriptions.

JSDoc Changes in TypeScript 7

TypeScript 7 brought no new language features, but it reworked how JavaScript files are checked. Two patterns common in older projects no longer work: @enum and Closure-style function types. First, @enum:

// @ts-check
/** @enum {string} */
const Status = {
  Active: "active",
  Archived: "archived",
};

/** @param {Status} status */
function show(status) {
  console.log(status);
}
show("active");

TypeScript 7.0.2 doesn't accept this:

error TS2749: 'Status' refers to a value, but is being used as a type here. Did you mean 'typeof Status'?

This is part of a general rule in 7.0: in JSDoc, a value can no longer be used as a type. The fix is to separate the values from the type, for example by defining a union with @typedef {"active" | "archived"} Status and giving the object another name. The Closure-style {function(string): number} notation is now a syntax error too (TS1005); use TypeScript's own notation instead: {(text: string) => number}. When you move an older JSDoc-heavy project to TypeScript 7, these are most likely the first errors you'll run into.

Converting Files to .ts

Once a file is clean under // @ts-check, converting it to .ts is a small job. Order matters: start with the "leaf" files that depend on no other file, meaning helper functions and constants. As they get typed, the files that use them gain types for free. Here's the .ts version of the JSDoc file above:

interface Product {
  id: string;
  name: string;
  price: number;
}

interface CartItem {
  product: Product;
  quantity: number;
}

const items: CartItem[] = [];

export function addToCart(product: Product, quantity = 1) {
  items.push({ product, quantity });
}

export function last<T>(list: T[]): T | undefined {
  return list[list.length - 1];
}

The JSDoc tags map one-to-one onto TypeScript syntax: @typedef becomes an interface, @template T becomes <T>, and [quantity] becomes a parameter with a default value. One warning: in .ts files, types in JSDoc are not used. If you convert the file and leave a @param {number} x comment behind, x stays untyped and in strict mode you get TS7006, "Parameter 'x' implicitly has an 'any' type." Move the types from the comments into the code as you convert.

Gradual strict

Since TS 6.0, strict is on by default, and there's no reason to turn it off in a new project. In an old project, though, switching on the whole strict family at once becomes the moment of the migration that produces the most errors. The practical route is to leave strict on and temporarily turn off its noisiest one or two members. In the tsconfig above, noImplicitAny: false does exactly that: in a file you've converted to .ts, parameters whose types you haven't written yet quietly become any, so converting a file doesn't suddenly require hundreds of annotations.

Once most files are converted, you turn on noImplicitAny, then any other settings you switched off, such as strictNullChecks, if needed. With each switch the error count stays manageable, and each step can be merged as a separate small change. The goal is always the same: no temporarily disabled setting left in the tsconfig.

@ts-expect-error or @ts-ignore?

During a migration you can't fix some errors right away. There are two ways to silence the error on the next line:

function parsePort(value: string): number {
  return Number(value);
}

// @ts-expect-error legacy call, fixed once the config module moves
const legacy = parsePort(8080);

// @ts-ignore
const hidden = parsePort(3000);

// @ts-expect-error
const fine = parsePort("8080");

Both silence the error on the next line. The difference shows up when the error goes away. @ts-ignore stays quiet forever; whether the line below gets fixed or turns into a different error, it says nothing. @ts-expect-error, on the other hand, says it expects an error, and if there is none, it becomes an error itself. You can see this on the last line: parsePort("8080") is already correct, so tsc reports TS2578, "Unused '@ts-expect-error' directive." In other words, when someone fixes the problem, the compiler itself reminds them to delete the now-unneeded silencer. In a migration, use @ts-expect-error and write an explanation next to it; searching for these lines and reducing them one by one then turns into a to-do list.

Common Pitfalls

The first is turning on checkJs for the whole project from the start. When the team sees hundreds of errors, they lose interest in the migration. Proceed file by file with // @ts-check; once most files are clean, turn on checkJs and mark the rest with // @ts-nocheck.

The second is silencing everything with any. noImplicitAny: false is a temporary bridge, not a permanent home. As anys pile up, the safety TypeScript gives you quietly shrinks.

The third is assuming JSDoc types still work in a file you've converted to .ts. Move the types into the code the moment you convert.

The fourth is assuming that a JSDoc project that checks cleanly with TypeScript 6 will stay clean in 7. Clean up @enum and Closure-style notations beforehand.

A Small Experiment

★☆☆ Predict whether it compiles: in the opening cart.js, write Number(input?.value) instead of input.value. How many errors are left?

★★☆ Make the @enum example work in TypeScript 7: rename the object to StatusValues and define a union called Status with @typedef. Confirm that a show("deleted") call is now caught.

★★★ If you have an old JavaScript project, or if you write a small one, start with this post's tsconfig. First add // @ts-check to one file and fix its errors, then convert the file to .ts. At each step, note the number of errors in the output of npx tsc --noEmit.

In a gradual migration, type checking itself takes the most time, which is why tsc's speed matters in large projects. In the next post we'll look at the compiler that was rewritten from scratch with exactly that in mind: TypeScript 7, and what comes after it.