Skip to Content
Migrate One Module While Testing Its JavaScript Boundary
Advanced
0 likes
Tutorials

Migrate One Module While Testing Its JavaScript Boundary

Convert one JavaScript module, catch a lost runtime guard, and verify an unchanged JavaScript caller against the emitted TypeScript build.

Converting a JavaScript file can pass the compiler and still change what JavaScript callers receive. This small migration keeps one caller in JavaScript, checks emitted code, and catches the runtime validation that a type annotation cannot replace.

1. Define the boundary before renaming a file

Our job-code function accepts a non-negative safe integer. It returns JOB-007 for 7 and rejects a string such as "7". The test suite will keep that behavior fixed. Use a new practice folder with Node and npm; the fixture was checked with Node 24.19.0 and TypeScript 7.0.2. For the broader migration sequence, see the course lesson.

Save package.json:

{
  "name": "mixed-boundary-lab",
  "private": true,
  "type": "module",
  "devDependencies": {
    "typescript": "7.0.2"
  },
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "test": "tsc -p tsconfig.json && node --test test/*.test.js"
  }
}

Save tsconfig.json. Source and output folders are separate so the compiler does not overwrite JavaScript input.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "strict": true,
    "allowJs": true,
    "checkJs": false,
    "rootDir": "src",
    "outDir": "dist",
    "noEmitOnError": true
  },
  "include": [
    "src/**/*"
  ]
}

2. Capture the existing JavaScript contract

Create src/code.js:

export function jobCode(count) {
  if (!Number.isSafeInteger(count) || count < 0) {
    throw new TypeError("count must be a non-negative safe integer");
  }
  return `JOB-${String(count).padStart(3, "0")}`;
}

Create src/report.js. Leave its .js import extension unchanged during conversion; it names the emitted module Node will execute.

import { jobCode } from "./code.js";
export function report() {
  return jobCode(7);
}

Create test/code.test.js. Tests import dist, so they exercise the compiled/copied result:

import test from "node:test";
import assert from "node:assert/strict";
import { jobCode } from "../dist/code.js";
import { report } from "../dist/report.js";

test("unchanged JavaScript consumer", () => assert.equal(report(), "JOB-007"));
test("zero is valid", () => assert.equal(jobCode(0), "JOB-000"));
test("JavaScript string is rejected", () => assert.throws(() => jobCode("7"), TypeError));
test("fraction is rejected", () => assert.throws(() => jobCode(1.5), TypeError));
npm install
npm test

All four tests must pass before conversion. allowJs lets these JavaScript sources participate in the build; checkJs: false leaves their type diagnostics off. Our strict settings still check TypeScript files.

3. Try the tempting but incomplete conversion

Rename the source, rather than keeping both code.js and code.ts:

Rename-Item src/code.js code.ts

Replace its contents with this deliberately broken conversion:

export function jobCode(count: number): string {
  return `JOB-${String(count).padStart(3, "0")}`;
}
npm run build
node --test test/code.test.js

The build succeeds. Two tests fail: the string and fractional inputs no longer throw. A number annotation is erased from emitted JavaScript. It neither validates callers at runtime nor expresses the narrower safe-integer rule.

4. Restore the guard and verify the same caller

Use this complete src/code.ts, preserving the runtime rule:

export function jobCode(count: number): string {
  if (!Number.isSafeInteger(count) || count < 0) {
    throw new TypeError("count must be a non-negative safe integer");
  }
  return `JOB-${String(count).padStart(3, "0")}`;
}

Run npm test again. All four tests pass while src/report.js remains JavaScript. This is a useful stopping point: one converted module, unchanged import paths, preserved behavior and a working emitted build. Commit the source, configuration, tests and lockfile; keep generated output under your project's usual policy.

5. Expose an unchecked JavaScript caller

Temporarily change jobCode(7) in report.js to jobCode("7"). With checkJs: false, the build still passes, but the consumer test fails with the runtime TypeError. Add // @ts-check as the first line of that JavaScript file and build again: TS2345 now catches the string argument before execution. Restore 7, keep the directive and confirm all four tests pass.

After a failed build, do not test an older dist folder and call it the new result. noEmitOnError prevents new output on errors; it does not remove earlier output. The test script uses && so a failed compile stops that run.

Observed resultNext check
Build passes; invalid-input tests failRestore runtime validation; annotations do not execute.
Wrong JavaScript argument compilesOpt that file into @ts-check or plan broader checkJs adoption.
Module cannot be resolved after renameCheck the emitted .js path and NodeNext package/module settings.
Old output runs after a compiler errorStop on the failed build and verify a successful fresh output before testing.

6. Exercise the boundary you have not covered

Add a test asserting that jobCode(-1) throws TypeError. It should pass with the repaired guard. This extends evidence for the contract without converting another file. The same pattern applies to JSON, forms and API inputs: checked callers and runtime validation solve different parts of the boundary.

References: allowJs, checkJs and Node's test runner.

About

Convert one JavaScript module, catch a lost runtime guard, and verify an unchanged JavaScript caller against the emitted TypeScript build.

Responsible
Christopher R
Last Update
10/13/2026
Reading Time
5 min
Was this helpful?
​ 0 ​ 0

Comments (0)

Sign in to comment

No comments yet.