Quick take
Stop treating TypeScript migration as a rewrite project. Flip one compiler flag, rename your boundary files to .ts, and tighten strictness over weeks. If the migration takes longer than shipping features, you’re doing it wrong.
The codebase that convinced me
At the fintech startup we had a Node.js backend serving financial news to tens of thousands of users. JavaScript everywhere. It worked until it didn’t.
The breaking point was a refactor to our news ranking pipeline. Someone renamed a field from relevanceScore to score in one module. The change looked clean, the tests passed (because the tests mocked the data with the new name), and it shipped. Two days later we noticed ranking was silently broken in production. The field name mismatch meant scores came through as undefined, and our sorting function treated undefined as zero. Every article ranked the same. Users saw noise instead of signal.
That kind of bug can’t happen in TypeScript. The compiler catches it instantly. Not a fancy type trick. Just basic structural checking. After that incident I decided we were migrating.
Why I was annoyed about it
I’m primarily a Go developer. Go has had static types from day one. The idea that a language community needed years of debate to arrive at “maybe we should check types before running code” felt absurd to me. TypeScript shouldn’t feel like a revelation. It should feel like the floor.
But here we are. JavaScript codebases exist, they power real products, and rewriting them in Go (tempting as that sounds) isn’t always practical. So you migrate. Incrementally. Without drama.
The approach that actually works
Forget the blog posts showing a pristine greenfield TypeScript project with perfect types. Real migrations happen in messy codebases with deadlines.
Step one: make TypeScript compile your existing JavaScript. Add a tsconfig.json that allows JS files and skips type checking them. This changes nothing about your build. It just proves the tooling works.
{
"compilerOptions": {
"target": "ES2017",
"module": "commonjs",
"strict": false,
"allowJs": true,
"checkJs": false,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
Step two: rename your boundary files. Not all of them. Start with the files that define the shapes of data crossing trust boundaries. API request handlers. Database query result types. Config loaders. These are where type mismatches actually cause production bugs. Rename them from .js to .ts and add types to the function signatures.
export interface Article {
id: string;
title: string;
relevanceScore: number;
}
export function parseArticle(input: unknown): Article {
const data = input as Record<string, unknown>;
return {
id: String(data.id),
title: String(data.title),
relevanceScore: Number(data.relevanceScore),
};
}
That parseArticle function isn’t fancy. It’s a boundary. Everything downstream of it knows what shape it’s working with. The field rename bug that bit us at the fintech startup becomes a compile error instead of a silent production failure.
Step three: tighten the compiler gradually. Don’t flip strict: true on day one. You will get a thousand errors and your team will revolt. Instead, enable checks one at a time over a few weeks:
noImplicitAnyfirst. This catches the worst category of silent failures.strictNullChecksnext. This eliminates theundefinedsurprise class of bugs.- Full
strictonce the team has momentum and the backlog of type errors is manageable.
You can even run a stricter config on the directories you have already cleaned up while leaving the rest permissive:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"strict": true
},
"include": ["src/models/**/*", "src/utils/**/*"]
}
What kills migrations
I’ve seen three TypeScript migrations fail. Same pattern every time.
Making it a side project. If migration work isn’t part of normal sprints, it will never finish. The approach that works: new files are .ts by default, and any file you touch for a feature gets migrated as part of that feature work. No separate migration tickets that rot in the backlog.
Sprinkling any everywhere and calling it done. Renaming .js to .ts and casting everything to any isn’t a migration. It’s a lie that makes the build green while providing zero safety. If you find yourself typing as any more than once per file, stop and think about what you’re avoiding.
Converting giant files first. A 2000-line controller file isn’t where you start. Break it up first, then type the pieces. Trying to add types to a monolith module is miserable and teaches the team that TypeScript migration is miserable. Start with small utility files. Quick wins build momentum.
Third-party types
Most popular libraries ship types or have them on DefinitelyTyped. When they don’t, declare the module and move on:
declare module "legacy-widget";
This isn’t ideal, but it’s honest. You’re saying “I don’t know the types for this dependency and I’m not going to pretend.” Better than a wrong type definition that gives false confidence.
Keep the pressure on
Run tsc --noEmit in CI from day one. Make it a blocking check. Track your migration with something embarrassingly simple like counting file extensions:
echo "JS: $(find src -name '*.js' | wc -l)"
echo "TS: $(find src -name '*.ts' | wc -l)"
When the numbers cross over, buy the team lunch. Seriously. Celebrate boring infrastructure wins. They are the ones that actually matter.
The honest trade-off
TypeScript adds friction. Build times go up. Editor tooling sometimes chokes on complex types. The type system has genuine holes (any is a backdoor, type assertions bypass checking, and some runtime patterns are hard to express statically).
But the trade-off is overwhelmingly worth it for any codebase with more than one contributor that will exist for more than six months. TypeScript directly improves stability by catching an entire class of defects before code reaches production. That alone justifies the migration cost.
Just do it incrementally. Do it as part of real work. And for the love of shipping, don’t make it a three-month rewrite project.