Move a JavaScript module to strict TypeScript one flag at a time, with the compiler and your tests as the gate
A strictness ladder for a JS-to-TS migration that an agent can climb in small batches without changing behaviour by accident. About 20 minutes to try on one module.

A 400-line JavaScript module has to become TypeScript, and the obvious plan is to rename every file to .ts, flip strict on and let an agent grind through the errors. That plan produces a diff nobody can review. Hundreds of errors arrive at once, the fastest way to silence them is any or a cast, and somewhere in the pile a “fix” quietly changes what the function returns.
A ladder works better. Each rung is one compiler setting. The agent climbs one rung per batch, the compiler says what’s left, and a behaviour test says nothing else moved. Every batch is small enough to read.
None of this needs a particular agent. The technique, the prompts and the check that proves it worked all work with anything that can read files and run a shell, and it works with no agent at all.
The setup
The lab here is deliberately tiny: two JavaScript files and a Vitest file, enough to show every step. The module prices an order and greets a customer.
// src/pricing.js
export function lineTotal(line) {
return line.unitPrice * line.quantity;
}
export function applyDiscount(total, code, codes) {
const match = codes.find((c) => c.code === code);
const percent = match.percent;
return Math.round(total * (100 - percent)) / 100;
}
export function orderTotal(order, codes) {
const subtotal = order.lines.reduce((sum, line) => sum + lineTotal(line), 0);
if (!order.discountCode) return subtotal;
return applyDiscount(subtotal, order.discountCode, codes);
}
// src/customers.js
export function displayName(customer) {
const parts = [customer.firstName, customer.lastName].filter(Boolean);
return parts.length ? parts.join(" ") : customer.email;
}
export function findCustomer(customers, id) {
return customers.find((c) => c.id === id);
}
export function greeting(customers, id) {
const customer = findCustomer(customers, id);
return "Hello, " + displayName(customer);
}
Install the two tools: npm i -D typescript vitest. At the time of writing npm’s latest tag for TypeScript is 7.0.2, announced as 7.0 on 8 July 2026. The compiler output below came from that version.
Rung zero: pin the behaviour
Before any type goes in, write down what the code does today, in tests that are not going to be migrated. They stay JavaScript on purpose, so the thing that checks the migration isn’t being changed by the migration.
// src/pricing.test.js
import { describe, expect, it } from "vitest";
import { orderTotal } from "./pricing.js";
import { greeting } from "./customers.js";
const codes = [{ code: "SAVE10", percent: 10 }];
const order = { lines: [{ unitPrice: 20, quantity: 2 }, { unitPrice: 5, quantity: 1 }] };
describe("behaviour that must survive the migration", () => {
it("totals lines", () => expect(orderTotal(order, codes)).toBe(45));
it("applies a known code", () => expect(orderTotal({ ...order, discountCode: "SAVE10" }, codes)).toBe(40.5));
it("greets by full name", () =>
expect(greeting([{ id: 1, firstName: "Ada", lastName: "Lovelace", email: "a@x.io" }], 1)).toBe("Hello, Ada Lovelace"));
it("falls back to email", () =>
expect(greeting([{ id: 2, email: "b@x.io" }], 2)).toBe("Hello, b@x.io"));
});
Test Files 1 passed (1)
Tests 4 passed (4)
In a real module this is the step that takes time, and an agent is good at it: ask it to write characterisation tests for the exported functions, then read them yourself. The earlier Tokenise piece on characterisation grids before a refactor covers that step in depth. For this piece, the only requirement is a command that exits non-zero when behaviour changes.
Rung one: check the JavaScript as it is
Add a tsconfig.json that type-checks the JavaScript without converting anything.
{
"compilerOptions": {
"target": "es2022",
"module": "esnext",
"moduleResolution": "bundler",
"allowJs": true,
"checkJs": true,
"noEmit": true,
"strict": false,
"types": []
},
"include": ["src"]
}
Two details matter. checkJs is documented as the equivalent of putting // @ts-check at the top of every included JavaScript file, so this rung already gives the compiler a view of the code with zero edits. And "strict": false has to be written out. TypeScript 6.0 changed the default of strict from false to true, and the release post tells anyone relying on the old default to set it explicitly. A fresh config is strict, so a migration config that leaves it out is on the top rung before it has started.
Run npx tsc --noEmit. On this module it prints nothing and exits 0. That’s the baseline: the compiler sees the code and has no complaint, which says little about the code and a lot about how loose the starting settings are.
Rung two: rename and type, with noImplicitAny on
Now the files become TypeScript. Do it with the rename and the first flag together, because noImplicitAny is what turns “untyped” into a list of work. Before touching anything, ask what the list looks like by switching the flag on in a copy of the config:
src/customers.js(1,29): error TS7006: Parameter 'customer' implicitly has an 'any' type.
src/customers.js(6,30): error TS7006: Parameter 'customers' implicitly has an 'any' type.
src/pricing.js(5,31): error TS7006: Parameter 'total' implicitly has an 'any' type.
src/pricing.js(11,28): error TS7006: Parameter 'order' implicitly has an 'any' type.
... (15 errors in total, all TS7006)
Fifteen errors, all the same kind: a parameter has no type. That is a batch an agent can do, and a reviewer can check by looking at signatures only. Put the shared shapes in one file so the agent has somewhere to write them down:
// src/types.ts
export interface Line { unitPrice: number; quantity: number }
export interface Order { lines: Line[]; discountCode?: string }
export interface DiscountCode { code: string; percent: number }
export interface Customer { id: number; firstName?: string; lastName?: string; email: string }
A prompt that keeps the batch honest:
Rename src/pricing.js and src/customers.js to .ts and add parameter and return types
using the interfaces in src/types.ts. Rules: change signatures only. Don't change any
function body, don't use any, as or non-null assertions (!), and don't edit tests.
Done when "npx tsc -p tsconfig.noImplicitAny.json" and "npx vitest run" both pass.
If a type can't be written without changing behaviour, stop and list it.
The last line is the important one. It gives the agent a legitimate way to say “this one is a real problem” rather than burying it in a cast.
After the signature edits, tsc exits 0 under noImplicitAny and the 4 tests still pass. The diff is signatures and one new file. That’s a ten-minute review.
Rung three: strictNullChecks, where the real bugs live
Run the same config with strictNullChecks on and, this time, the typed code has something to say:
src/customers.ts(14,34): error TS2345: Argument of type 'Customer | undefined' is not assignable to parameter of type 'Customer'.
Type 'undefined' is not assignable to type 'Customer'.
src/pricing.ts(9,19): error TS18048: 'match' is possibly 'undefined'.
Two errors, and both are real defects that were in the JavaScript all along. greeting throws a TypeError for an unknown customer id. applyDiscount throws for a discount code that isn’t in the list. Each is a one-line fix that a typical agent would make without being asked, and that is exactly the problem: the fix is a behaviour change, and the right behaviour is a product decision. Should an unknown code be ignored or rejected? Should a missing customer greet as a guest or raise an error?
Note the order here. Run strictNullChecks before the parameters are typed and it reports nothing at all, because untyped parameters are any and any is never null. The rung only means something once the types from the previous one are in. That’s the argument for a ladder over a single flip: each flag’s output depends on the flags below it.
So this rung has a different shape of prompt. The agent doesn’t pick the behaviour, it proposes tests that pin each option, and a person chooses:
tsc reports two strictNullChecks errors. For each one, don't change the code yet.
Write a failing Vitest test that shows today's behaviour (it throws), then list the
two or three sensible behaviours with a one-line trade-off each. I'll choose.
Here the choice was: an unknown code means no discount, and a missing customer greets as “guest”. Two tests go in first and fail against the old code, then the fixes:
// pricing.ts
const percent = match?.percent ?? 0;
// customers.ts
return "Hello, " + (customer ? displayName(customer) : "guest");
it("ignores an unknown code (was a TypeError)", () =>
expect(orderTotal({ ...order, discountCode: "NOPE" }, codes)).toBe(45));
it("greets a missing customer as guest (was a TypeError)", () =>
expect(greeting([], 9)).toBe("Hello, guest"));
Tests 6 passed (6)
The migration changed behaviour twice, on purpose, in two lines, each with a test that names the change. That is the outcome to aim for. An undisciplined migration changes behaviour a dozen times and mentions none of them.
Rung four: the rest of strict
Now replace the individual flags with "strict": true. It is documented as enabling nine sub-flags: alwaysStrict, strictNullChecks, strictBindCallApply, strictBuiltinIteratorReturn, strictFunctionTypes, strictPropertyInitialization, noImplicitAny, noImplicitThis and useUnknownInCatchVariables. On this tiny module the remaining seven add nothing, and tsc exits 0.
On a real codebase they won’t be empty. Take them one at a time anyway, in this order, which goes from least to most likely to surface something awkward:
noImplicitThisandalwaysStrict: usually quiet.strictBindCallApplyandstrictBuiltinIteratorReturn: a handful of fixes where code leans on.bindor iterator results.useUnknownInCatchVariables: everycatch (e)that readse.messagenow needs a narrowing check. Mechanical, but it touches a lot of lines.strictFunctionTypesandstrictPropertyInitialization: these hit class-heavy code and callback-heavy code hardest, and are the likeliest to want a design decision.
When the last one is on, delete the individual flags and the copies of the config. A single "strict": true is the end state, and the ladder was only scaffolding.
What goes wrong
Agents reach for escape hatches. Under pressure to make tsc exit 0, any, as unknown as T and the ! operator are the shortest path, and each one deletes the information the flag was added to get. Make “no any, no as, no !” part of the brief, and grep the diff for them before reading anything else: git diff | grep -nE ':\s*any\b|\bas (any|unknown)\b|[a-z\)\]]!\.'. Expect some false positives.
Generated types can be wrong in a way the compiler can’t see. An interface that says email: string compiles whether or not every real record has an email. Types at the edges of the module (parsed JSON, database rows, API responses) deserve a runtime check, not just an annotation, and that’s a separate task from this migration.
Tests only pin what they exercise. A green Vitest run after the typed rename means the four paths in the file still work, not that nothing changed. The thinner the tests, the more a human has to read in the diff.
Don’t bother with the ladder for a module you’re about to delete, or for a tiny one with good tests where flipping strict and fixing five errors takes five minutes. It earns its keep from a few dozen errors upward, and where the module has callers you can’t see.
The first 15 minutes
Pick one module with a test file, or write four quick tests against its exports. Add the allowJs and checkJs config with "strict": false, run npx tsc --noEmit, then enable noImplicitAny in a copy of the config and count the errors. The count tells you whether the rung is a ten-minute batch or an afternoon, before anything has been edited.