RizTech Academy logo
RizTech Academy
TypeScript in Real ProjectsLesson 5 of 630 min

Adding TypeScript to an existing JavaScript project

Most TypeScript in the world is not written from scratch — it is added to a JavaScript codebase that already exists and already works. Doing that badly means a week of red errors and a demoralised team; doing it well means the codebase gets safer file by file with the app never breaking. This lesson is the honest, ordered playbook for migrating an existing JavaScript project to TypeScript — the strategy, not just the syntax.

The wrong way, and why it fails

The instinct is to flip everything at once: rename every .js to .ts, turn strict on, and start fixing. On any real codebase this produces hundreds or thousands of errors simultaneously — every implicit any, every unhandled null, every loose call, all at once. The team cannot ship while it is red, the errors are overwhelming, and someone eventually gives up and adds // @ts-nocheck everywhere, which is migrating to TypeScript in name only.

The principle that avoids this: migrate incrementally, keep the app shippable at every step, and tighten strictness after files are converted, not during. TypeScript is designed for exactly this — it can type-check JavaScript, allow the two languages to coexist, and ratchet strictness up gradually. Use those affordances instead of fighting them.

Step 1: let TypeScript coexist with JavaScript

Add a tsconfig.json with allowJs and, at first, strict off. This lets .ts and .js live in the same project and import each other:

{
  "compilerOptions": {
    "allowJs": true,        // compile .js files too — coexistence
    "checkJs": false,       // don't type-CHECK .js yet (just allow it)
    "strict": false,        // start loose — we tighten later
    "outDir": "./dist",
    "target": "ES2020",
    "moduleResolution": "NodeNext"
  },
  "include": ["src/**/*"]
}

Now nothing breaks: your JavaScript still compiles and runs exactly as before, but the project is a TypeScript project and you can start adding .ts files. This coexistence is the foundation of the whole migration — you are never in an all-or-nothing state.

Step 2: get value before converting anything, with checkJs

Before renaming a single file, you can have TypeScript check your JavaScript using JSDoc comments. Turn on checkJs (or mark individual files with // @ts-check), and annotate with JSDoc:

// @ts-check

/**
 * @param {string} name
 * @param {number} age
 * @returns {string}
 */
function describe(name, age) {
  return `${name} is ${age}`;
}

describe("Asha", "twenty");   // ERROR — TypeScript checks this from the JSDoc

This is a genuinely useful intermediate step: you get type-checking on the existing JavaScript, catching real bugs, before committing to renaming files. On a large codebase, // @ts-check at the top of your most bug-prone files can find problems in an afternoon with no .ts conversion at all. It also teaches the team the type system gradually.

Step 3: convert file by file, leaf-first

Now rename .js to .ts, one file at a time, starting with leaf modules — files that few others depend on, or that depend on nothing internal (utilities, constants, pure helpers). Why leaf-first: when you convert a file, its exported types start flowing to everything that imports it, so converting a widely-used module last means less churn, and converting a leaf first gives you a typed foundation to build on.

For each file:

  1. Rename .js to .ts.
  2. Fix the errors that appear — usually a handful of implicit any parameters to annotate.
  3. Where the correct type is genuinely unclear, use any temporarily and leave a // TODO: type this — the goal of this pass is converted and compiling, not perfect. You tighten in step 5.
  4. Commit. The app still runs; one more file is typed.

Because allowJs is on, the still-.js files keep working throughout. You are never blocked, and you can stop and ship at any point.

Step 4: type the boundaries first, any the middle

If you have limited time, spend it where types catch the most bugs: the boundaries — API responses, form inputs, database rows, function signatures that many callers use. A correctly-typed boundary protects everything downstream of it. The internal plumbing of a function matters far less; an any buried inside one function's body is contained, whereas an any on a widely-called export leaks everywhere. So annotate public signatures and external data carefully, and do not agonise over every local variable — inference handles most of those anyway.

Step 5: tighten strictness, one flag at a time

Only after files are converted do you turn strictness up — and even then, incrementally. The order that causes the least pain:

"noImplicitAny": true,      // first — forces annotating the `any`s you left behind
// fix the errors, commit, then:
"strictNullChecks": true,   // the big one — surfaces every unhandled null
// fix, commit, then:
"strict": true              // finally, the whole family

Each flag surfaces a category of latent bug. Turning them on one at a time means a reviewable number of errors per step instead of a wall. strictNullChecks is usually the largest single jump — budget real time for it — which is exactly why the strict-mode lesson urged turning it on from day one on new projects: this step is the cost you avoid by starting strict.

A useful tool here is tsc --noEmit in CI from the start, so the type-check runs on every commit and strictness cannot silently regress. And // @ts-expect-error (with a comment) is better than // @ts-ignore for the errors you defer, because it fails if the error ever goes away — a self-cleaning TODO.

The realistic timeline, and when not to bother

Be honest with yourself and your team: a substantial codebase is a weeks-to-months migration done properly, not an afternoon. That is fine — the app ships the entire time. But it means the decision to migrate should be deliberate: it pays off most on code that is actively developed and bug-prone, and least on stable code that rarely changes (a working, rarely-touched module gains little from types). Migrate the parts under active development first; leave the frozen corners as .js indefinitely if they are not worth the effort. TypeScript coexisting with JavaScript forever is a perfectly valid end state, not a failure.

Check your work

Why "flip everything at once" fails. It surfaces hundreds of errors simultaneously, blocks shipping, and ends in @ts-nocheck — migration in name only.

The guiding principle. Migrate incrementally, keep the app shippable at every step, tighten strictness after conversion, not during.

What allowJs gives. .ts and .js coexist and import each other — the app never breaks mid-migration.

What checkJs / // @ts-check gives before converting. Type-checking of existing JavaScript via JSDoc — real bugs found with no .ts rename.

The conversion order. File by file, leaf-first (few dependants), using temporary any where the type is unclear; commit after each.

Where to spend limited time. The boundaries — API responses, external data, widely-used signatures — not the internal plumbing.

How to tighten strictness. One flag at a time: noImplicitAny, then strictNullChecks (the big jump), then full strict; tsc --noEmit in CI throughout.

The realistic scope. Weeks to months for a large codebase, app shipping throughout; migrate actively-developed code first, leave frozen .js as-is.

Practice

  1. Take a small JavaScript project, add a tsconfig.json with allowJs: true and strict: false, and confirm it still builds and runs.
  2. Add // @ts-check and JSDoc types to one .js file; introduce a type mismatch and confirm TypeScript catches it without renaming the file.
  3. Convert one leaf module to .ts, annotate its parameters, and confirm the still-.js files that import it keep working.
  4. Convert a file where a type is genuinely unclear; use a temporary any with a // TODO, and reason about why "converted and compiling" is the right goal for the first pass.
  5. Turn on noImplicitAny alone, fix the errors, then strictNullChecks alone, and observe the difference in how many errors each surfaces.
  6. Replace a // @ts-ignore with // @ts-expect-error, then fix the underlying error, and watch the expect-error line itself become an error — the self-cleaning TODO.

Official documentation

Next: typing API responses you do not control.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship