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:
- Rename
.jsto.ts. - Fix the errors that appear — usually a handful of implicit
anyparameters to annotate. - Where the correct type is genuinely unclear, use
anytemporarily and leave a// TODO: type this— the goal of this pass is converted and compiling, not perfect. You tighten in step 5. - 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
- Take a small JavaScript project, add a
tsconfig.jsonwithallowJs: trueandstrict: false, and confirm it still builds and runs. - Add
// @ts-checkand JSDoc types to one.jsfile; introduce a type mismatch and confirm TypeScript catches it without renaming the file. - Convert one leaf module to
.ts, annotate its parameters, and confirm the still-.jsfiles that import it keep working. - Convert a file where a type is genuinely unclear; use a temporary
anywith a// TODO, and reason about why "converted and compiling" is the right goal for the first pass. - Turn on
noImplicitAnyalone, fix the errors, thenstrictNullChecksalone, and observe the difference in how many errors each surfaces. - Replace a
// @ts-ignorewith// @ts-expect-error, then fix the underlying error, and watch the expect-error line itself become an error — the self-cleaning TODO.
Official documentation
- TypeScript — Migrating from JavaScript — The official incremental-migration guide.
- TypeScript — Type Checking JavaScript Files —
checkJs,@ts-check, and JSDoc types. - TypeScript — JSDoc Reference — Every JSDoc annotation TypeScript understands.
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