“Just rewrite it in TypeScript” is easy to say in a planning meeting and a reliable way to stop shipping anything for a month while a rewrite drags on and drifts out of sync with whatever the JavaScript version keeps doing in production. The actual question worth answering is how to get real type safety incrementally, without a rewrite and without a big-bang cutover, and it turns out you can start before a single .ts file exists.

Step zero: turn on type checking against your existing .js files

Adding // @ts-check to the top of a plain JavaScript file makes the TypeScript compiler check it, using JSDoc comments for type information, with zero syntax changes to the file itself. This is the actual starting point, before any migration plan, before touching tsconfig.json: pick one file, add the comment, and see what the checker already knows is wrong.

// @ts-check

/**
 * @param {string} userId
 * @param {{ email: string, name?: string }} updates
 * @returns {Promise<void>}
 */
async function updateUser(userId, updates) {
  return api.patch(`/users/${userId}`, updates);
}

updateUser(123, { email: "a@b.com" }); // error: 123 is not a string

That error is real, and it was already a real bug risk before TypeScript ever entered the picture. This is the part that makes incremental adoption viable: you get genuine value from a single file, today, with no build step changes and no team-wide migration decision required first.

allowJs and checkJs: let the two file types coexist

Once it’s worth formalizing, a tsconfig.json with allowJs: true and checkJs: true lets .ts and .js files live in the same project indefinitely. New files can be written in real TypeScript from day one; existing files stay .js with @ts-check and JSDoc until someone has a real reason to convert them. There is no forced all-or-nothing moment.

Convert by import count, not by file size

The instinct is to start with the biggest file. The better signal is the file with the most importers, a shared API client, a core data-shaping utility, a widely used hook, because that’s where a wrong assumption about a shape currently causes silent bugs in every place that imports it. Typing that one file gives you leverage across the whole codebase immediately, since every caller now gets checked against a real shape instead of any by default.

Give yourself an any amnesty at the boundaries

API responses, third-party libraries without types, legacy code you’re not touching yet: type these as any explicitly, on purpose, rather than fighting to get them perfectly typed before you’ll allow the migration to proceed. An explicit, intentional any at a boundary is a marked debt you can tighten later. Blocking progress on making every boundary perfect from day one is the single most common reason incremental migrations stall and quietly get abandoned.

// Honest about what you don't know yet, not pretending it's fully typed
function parseWebhookPayload(body: any): WebhookEvent {
  // validated at runtime with zod/similar below, not just cast and trusted
  return webhookEventSchema.parse(body);
}

The real payoff isn’t the bugs it catches

Caught bugs are the pitch, but day to day the bigger win is editor support: autocomplete that actually knows the shape of the object you’re working with, and safe refactors, rename a prop and the editor shows you every call site that breaks, instead of finding out in a bug report three weeks after the rename shipped. That difference in how confident a refactor feels is what actually changes how a team works day to day, more than the type errors caught at compile time.

The actual takeaway

Incremental typing is slower to declare “finished” than a rewrite, because there’s no clean finish line, just a ratio of typed to untyped that keeps improving. It’s also the version that actually happens, because the codebase keeps shipping features the entire time instead of being frozen behind a migration branch that competes with everything else the team needs to build.