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 result | Next check |
|---|---|
| Build passes; invalid-input tests fail | Restore runtime validation; annotations do not execute. |
| Wrong JavaScript argument compiles | Opt that file into @ts-check or plan broader checkJs adoption. |
| Module cannot be resolved after rename | Check the emitted .js path and NodeNext package/module settings. |
| Old output runs after a compiler error | Stop 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
Reviews
Sign in to write a reviewComments (0)
Sign in to commentNo comments yet.