allowJs and checkJs — Gradual Adoption

8 min read

Two tsconfig.json flags control how TypeScript treats your JavaScript files during migration: allowJs and checkJs. Most guides mention them in passing. This lesson goes deeper, because getting them right is what separates a smooth incremental migration from one that stalls because CI keeps breaking.

What allowJs does

allowJs: true tells the TypeScript compiler to include .js files in its compilation. Without it, the compiler ignores every file that hasn't been renamed to .ts. With it, .js and .ts files participate in the same build — they can import each other, they share type information, and they appear in the same output (if you're compiling to dist/).

This is what makes incremental migration possible. A converted .ts file can import an unconverted .js file, and the .js file can import a .ts file. The dependency graph doesn't need to be untangled before you can start.

// login-page.ts (converted) — imports from a .js file
import { getBaseUrl } from "./config.js"; // works with allowJs: true
 
// config.js (not yet converted) — imported by a .ts file
module.exports = { getBaseUrl: () => process.env.BASE_URL };

What checkJs does

checkJs: true tells TypeScript to type-check .js files using whatever types it can infer — plus any JSDoc annotations you've added. Without checkJs, .js files compile but produce no errors. With it, TypeScript applies the same analysis to .js that it applies to .ts.

The practical difference:

// helper.js — with checkJs: true
 
/** @param {string} email @param {string} password */
function login(email, password) {
  return cy.session([email, password], () => {
    cy.visit("/login");
    cy.get("#email").type(email);
    cy.get("#password").type(password);
  });
}
 
login(42, "password");
// Error: Argument of type 'number' is not assignable to parameter of type 'string'

You get real type errors in .js files based on your JSDoc annotations — without renaming a single file.

The three useful combinations

allowJscheckJsEffect
truefalseJS files compile, no type errors reported — safe starting point
truetrueJS files compile and are type-checked via inference and JSDoc
falsen/aOnly .ts files compile — migration is complete

The typical migration moves through these phases in order.

Per-file overrides

Even with checkJs: false at the project level, you can opt individual .js files into type checking with a single comment at the top:

// @ts-check
// TypeScript will now type-check this file using inference and JSDoc
 
/** @param {{ email: string; role: string }} user @returns {string} */
function formatUserLabel(user) {
  return `${user.email} (${user.role})`;
}
 
formatUserLabel({ email: "alice@test.com" });
// Error: Argument of type '{ email: string; }' is not assignable.
// Property 'role' is missing.

The inverse also exists:

// @ts-nocheck
// Skip type checking for this file even if checkJs: true is set globally
// Useful for legacy files with too many errors to fix right now

Use // @ts-check to opt files in one at a time before renaming them. Use // @ts-nocheck sparingly, with a TODO comment, on files where the error count is too high to address immediately.

Line-level suppression

For individual errors inside a // @ts-check file, TypeScript provides two comments:

// @ts-ignore  ← suppresses the next line's error. No justification required.
const result = legacyHelper.doThing(data);
 
// @ts-expect-error  ← suppresses the next line's error AND fails if there is no error.
// Prefer this — it tells you when the suppression is no longer needed.
const result = legacyHelper.doThing(data);

// @ts-expect-error is the better suppressant during migration. When you later fix legacyHelper, TypeScript warns you that the suppression comment is now unnecessary — keeping the codebase clean.

A practical mid-migration Cypress project

Here's what a Cypress project looks like 40% through an incremental migration:

cypress/
├── e2e/
│   ├── login.cy.ts          ← converted, fully typed
│   ├── checkout.cy.ts       ← converted, fully typed
│   ├── search.cy.js         ← @ts-check at top, JSDoc annotations
│   └── reports.cy.js        ← not yet checked (allowJs only)
├── support/
│   ├── commands.ts          ← converted
│   ├── e2e.ts               ← converted
│   └── auth-helpers.js      ← @ts-nocheck (complex legacy code)
└── fixtures/
    └── users.json

This state is perfectly valid. The converted files get full TypeScript safety. The @ts-check files get partial safety via JSDoc. The remaining files compile but aren't checked. CI passes throughout.

Step 1 of 5

Phase 1: allowJs only

allowJs: true, checkJs: false. All .js files compile without errors. CI stays green. Baseline established.

JSDoc as a migration tool

JSDoc annotations let you type .js files precisely enough that TypeScript can verify callers:

// @ts-check
 
/**
 * @typedef {{ id: string; email: string; role: "admin" | "member" }} TestUser
 */
 
/**
 * @param {Partial<TestUser>} overrides
 * @returns {TestUser}
 */
function createTestUser(overrides = {}) {
  return {
    id: crypto.randomUUID(),
    email: "user@test.com",
    role: "member",
    ...overrides,
  };
}
 
createTestUser({ role: "superadmin" });
// Error: Type '"superadmin"' is not assignable to type '"admin" | "member"'

This is practical for fixture factories and shared helpers that you want to type before renaming them. The annotations carry forward when you eventually rename the file to .ts — TypeScript ignores them (they're comments) and uses the explicit TypeScript annotations you add instead.

⚠️ Common mistakes

  • Enabling checkJs: true project-wide before the codebase is ready. This will surface hundreds of errors in files you haven't addressed yet, and will make CI red until you suppress them all. Use // @ts-check per-file to control the rollout.
  • Using // @ts-ignore without a comment explaining why. An unexplained suppression is technical debt. Future maintainers won't know if the error is still relevant. Write // @ts-ignore — LegacyPaymentHelper has no types yet, tracked in #123.
  • Forgetting that JSDoc types are not TypeScript types. @param {string[]} in JSDoc is equivalent to string[] in TypeScript, but JSDoc has gaps — complex generic types are verbose and sometimes incorrect. When a JSDoc annotation gets unwieldy, it's a signal that this file is ready to be renamed to .ts.

🎯 Practice task

Take the project from the previous lesson (with TypeScript installed and allowJs: true, checkJs: false).

  1. Pick the simplest JavaScript utility file in the project — a config file, a URL helper, or a data factory.
  2. Add // @ts-check at the top of the file.
  3. Run npm run type-check. Note which errors appear.
  4. Add JSDoc annotations for the function parameters that have errors. Fix the errors.
  5. Add a deliberate mistake in a caller of one of the annotated functions — pass the wrong type. Confirm that npm run type-check catches it.
  6. Stretch: rename the file from .js to .ts. Remove the JSDoc annotations and replace them with TypeScript annotations directly on the function signatures. Run npm run type-check. The same errors should be caught — now by TypeScript's native syntax instead of JSDoc.

The next lesson covers strictness settings — which flags to enable, in what order, and how to handle the errors each one surfaces.

// tip to track lessons you complete and pick up where you left off across devices.