We Rewrote 50,000 Lines from JavaScript to TypeScript. It Wasn’t Worth It.
Six months, countless any types, and one honest retrospective later.
“We should migrate to TypeScript.”
Our senior developer said this during a code review where someone had passed a string to a function expecting a number. The bug had made it to production. The fix took five minutes. The TypeScript migration that followed took six months.
I’m not saying TypeScript is bad. I’m saying we approached it wrong, for the wrong reasons, at the wrong time. And I suspect we’re not alone.
The Promise of Type Safety
The pitch was compelling. TypeScript would catch bugs at compile time instead of runtime. Our code would be self-documenting. Refactoring would be safer. IDE autocomplete would be magical. Junior developers would make fewer mistakes.
All of this is technically true. What nobody mentioned was the cost.
We had a working JavaScript codebase. Not perfect, but stable. Our test coverage was decent. The team knew the code. We shipped features regularly without major incidents. But there was this nagging feeling that we could do better. That we should be more “professional.”
TypeScript felt like the mature choice. Every conference talk mentioned it. Every job posting required it. We convinced ourselves that migrating was an investment in code quality and developer experience.
Month One: Optimism and Any
We started with the easiest files. Utility functions, pure logic, things with clear inputs and outputs. The TypeScript compiler was strict, but manageable. We felt productive. We felt modern.
Then we hit our API layer. Years of JavaScript flexibility had created response objects that could be different shapes depending on various conditions. Sometimes a field was a string, sometimes an array, sometimes it didn’t exist at all. Making this work in TypeScript meant either redesigning the entire API contract or using any everywhere.
Here’s what we were dealing with:
// What we wanted to write
interface ApiResponse {
data: UserData;
meta: ResponseMeta;
}
// What we actually had to write
interface ApiResponse {
data: any; // Could be UserData, UserData[], or { users: UserData[] }
meta?: any; // Sometimes missing, sometimes nested differently
pagination?: any; // Only on certain endpoints
error?: any; // Shape depends on error type
}We chose any. Temporarily, we told ourselves. Just to keep moving.
That “temporary” any spread through the codebase like a virus. Complex React components with props that were sometimes passed, sometimes optional, sometimes different types? Any. Third-party libraries without good type definitions? Any. Code we didn’t fully understand but was afraid to break? Definitely any.
By week twelve, our metrics were embarrassing:
- 38% of type annotations were
anyorunknown - Average 12 type assertions (
as) per file - Build time increased from 45 seconds to 3.5 minutes
- Zero reduction in runtime errors
Six weeks in, we’d converted thirty percent of the codebase. But if you looked closely, we’d really just added type annotations without actual type safety. We had TypeScript syntax with JavaScript flexibility. The worst of both worlds.
The Real Cost Nobody Talks About
Feature development slowed to a crawl. Every new component needed type definitions. Every API change required updating interfaces in multiple files. Simple changes that took an hour now took half a day because the compiler kept complaining about type mismatches.
Our velocity metrics told the story:
- Sprint velocity dropped from 45 story points to 28
- Pull request cycle time increased 60% (2.3 days → 3.7 days)
- Time spent on “type wrangling” in code reviews: ~35% of review time
- Developer satisfaction score: dropped from 7.8 to 5.2 out of 10
Code reviews became philosophical debates. Should this be a union type or an intersection type? Is this optional property nullable or undefined or both? Should we use an interface or a type alias? Everyone had opinions. Nobody had answers that worked for every case.
The junior developers we were supposedly helping became more confused, not less. They’d write perfectly functional JavaScript, then spend hours fighting the TypeScript compiler to accept it. The learning curve wasn’t just steep — it was actively blocking productivity.
Our bundle size increased. Not dramatically, but noticeably. The TypeScript compiler output wasn’t as clean as our hand-written JavaScript. Minor, but it added up.
And the bugs? The runtime bugs we were trying to prevent? They barely decreased. Because most of our bugs weren’t type-related. They were logic bugs, race conditions, incorrect assumptions about user behavior. Things TypeScript couldn’t catch.
The Moment of Clarity
Four months into the migration, we had a critical bug in production. User payments were failing silently. The investigation revealed the issue: a TypeScript type definition said a field was always present, so we didn’t check for it. But in production, under certain conditions, the backend didn’t include that field.
The code looked like this:
interface PaymentResponse {
transactionId: string;
status: 'success' | 'failed';
receiptUrl: string; // We assumed this was always present
}
async function processPayment(data: PaymentResponse) {
// No null check because TypeScript says it's always there
await sendReceipt(data.receiptUrl);
// Runtime error: Cannot read property 'receiptUrl' of undefined
}The type system had given us false confidence. We’d stopped doing defensive programming because “TypeScript catches these issues.” Except it didn’t. It couldn’t. Type definitions are only as good as their accuracy, and our API’s runtime behavior didn’t always match our type definitions.
That same bug would have happened in JavaScript. But in JavaScript, we would’ve added the null check because we knew to be defensive. TypeScript made us complacent.
What We Actually Needed
Looking back, our original problem wasn’t the lack of types. It was the lack of tests and documentation. That production bug where someone passed a string instead of a number? Unit tests would’ve caught it. Proper JSDoc comments would’ve prevented it.
Instead of spending six months on TypeScript migration, we could have:
We increased our test coverage to 80%. comprehensive written documentation. Use ESLint to set up the correct linting rules. improved the methods used for code reviews. For external data, runtime validation was added using libraries like Zod or Yup. taught defensive programming to the group.
We could have used JSDoc for type hints without the compilation overhead:
/**
* @typedef {Object} PaymentData
* @property {string} transactionId
* @property {'success'|'failed'} status
* @property {string} [receiptUrl] - Optional, might not exist
*/
/**
* @param {PaymentData} data
* @returns {Promise<void>}
*/
async function processPayment(data) {
// JSDoc gives IDE hints, but we stay defensive
if (data.receiptUrl) {
await sendReceipt(data.receiptUrl);
}
}All of this would have prevented more bugs than TypeScript did, without the migration cost or the ongoing complexity.
TypeScript solved the problems we didn’t have. We thought our issue was type safety. Our real issues were testing discipline, communication, and rushed code reviews. Adding types didn’t fix any of that.
The Current State
We finished the migration. Technically. The codebase is now TypeScript. But it’s not what the TypeScript evangelists promise. We have type definitions with enough any to make them meaningless. We have complex generic types that nobody fully understands. We have type assertions when we couldn’t make the compiler happy otherwise.
Development is still slower than it was in JavaScript. New features require more boilerplate. The type definitions drift out of sync with reality. We spend time fixing type errors that don’t represent actual bugs.
Would I remove TypeScript now? Probably not. The cost of migrating back would be just as high. But would I recommend other teams follow this path? Only if they’re already bought into the TypeScript philosophy and starting fresh projects.
What I’d Do Differently
If I could start over, here’s what I’d change. First, I’d fix the actual problems instead of adding types. Better tests. Better documentation. Better practices. Types can’t substitute for these fundamentals.
If TypeScript still seemed valuable after that, I’d introduce it gradually. New features only. No big rewrite. Let the team learn TypeScript on small pieces without the pressure of converting everything.
I’d use TypeScript’s looser modes initially. Strict mode sounds professional, but it’s brutal on an existing JavaScript codebase. We could’ve been more pragmatic.
Most importantly, I’d question the assumption that TypeScript is always better. It’s a tool with tradeoffs. For some teams and projects, those tradeoffs make sense. For others, they don’t. We never asked if it made sense for us.
The Uncomfortable Truth
The TypeScript community doesn’t talk enough about the costs. Every article focuses on benefits. Catch bugs early. Better IDE support. Safer refactoring. All true, but incomplete.
The costs are real. Migration time. Ongoing complexity. Slower development. Steeper learning curve. These aren’t edge cases or signs you’re “doing it wrong.” They’re inherent to adding a type system to an existing dynamic language codebase.
TypeScript is good at what it does. But what it does might not be what you need. And admitting that after six months and fifty thousand lines converted is uncomfortable. But it’s honest.
Our codebase isn’t better because of TypeScript. It’s just different. We traded one set of problems for another set of problems. The bugs still happen. The code still needs review. The tests still need writing.
The language changed. The fundamental challenges of software development didn’t.
The Real Lesson
TypeScript isn’t magic. It won’t fix poor testing practices, unclear requirements, or rushed development. It won’t prevent logic errors, race conditions, or misunderstandings about business rules.
What it will do is add another layer to understand, another tool to learn, another thing to maintain. Sometimes that tradeoff is worth it. Often, especially for existing JavaScript projects, it’s not.
These days, when someone suggests migrating to TypeScript, I ask different questions. What specific problems are we trying to solve? Have we exhausted simpler solutions? Are we willing to accept slower development for type safety? Does the team actually want this?
Usually, the answers reveal that TypeScript isn’t the solution we need. It’s just the solution that sounds good in tech circles. And there’s a big difference between those two things.
Our fifty thousand lines are now TypeScript. But if I’m being honest, they’re not better. They’re just more verbose. And that’s okay to admit.
If this resonated with you, give it a clap (you can clap up to 50 times, and it helps other engineers find this).
Follow me for more honest takes on software development — the lessons we learn the hard way, without the BS.
Had a similar experience with TypeScript or other migrations? Drop it in the comments. We’re all learning from each other’s mistakes.
