Refactoring Old Projects with TypeScript Link to heading
After spending most of 2020 getting comfortable with modern JavaScript tooling, I decided to start 2021 by tackling something I’d been putting off: migrating some of my older JavaScript projects to TypeScript. The results have been both more challenging and more rewarding than I expected.
The Projects in Question Link to heading
I had several projects that were good candidates for TypeScript migration:
- A Node.js API server with about 3,000 lines of JavaScript
- A Vue.js frontend with complex state management
- A collection of utility scripts that had grown organically over time
- A small Express.js application with minimal documentation
All of these projects shared common problems: they worked, but understanding and modifying them required extensive mental mapping of data structures and function signatures.
The Migration Strategy Link to heading
Rather than attempting a “big bang” migration, I opted for TypeScript’s gradual adoption approach. The ability to mix TypeScript and JavaScript files in the same project is one of its greatest strengths for legacy codebases.
Phase 1: Configuration and Tooling Link to heading
Setting up the TypeScript compiler configuration was the first step:
{
"compilerOptions": {
"target": "es2018",
"module": "commonjs",
"lib": ["es2018"],
"allowJs": true,
"checkJs": false,
"outDir": "./dist",
"rootDir": "./src",
"strict": false,
"noImplicitAny": false,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}
Starting with strict: false and noImplicitAny: false allowed the existing JavaScript to compile without modification. The plan was to gradually tighten these settings as the codebase improved.
Phase 2: File-by-File Conversion Link to heading
I started with utility modules and pure functions; code with clear inputs and outputs and minimal external dependencies. These were the safest to convert and provided immediate value in terms of better IDE support.
// Before (JavaScript)
function formatCurrency(amount, currency) {
return new Intl.NumberFormat("en-AU", {
style: "currency",
currency: currency || "AUD",
}).format(amount)
}
// After (TypeScript)
function formatCurrency(amount: number, currency: string = "AUD"): string {
return new Intl.NumberFormat("en-AU", {
style: "currency",
currency,
}).format(amount)
}
Even simple type annotations like these caught several bugs where I was passing strings to functions expecting numbers.
Phase 3: Interface Definition Link to heading
The real value of TypeScript emerged when I started defining interfaces for the data structures used throughout the applications:
interface User {
id: string
email: string
name: string
createdAt: Date
preferences?: UserPreferences
}
interface UserPreferences {
theme: "light" | "dark"
notifications: boolean
timezone: string
}
Having these interfaces made the codebase self-documenting and caught numerous inconsistencies in how data was being handled across different modules.
Unexpected Benefits Link to heading
Better IDE Experience Link to heading
The improvement in IDE support was immediate and dramatic. IntelliSense suggestions became accurate and helpful, and refactoring tools could safely rename variables and functions across the entire codebase.
Documentation That Stays Current Link to heading
Type definitions serve as always-up-to-date documentation. Unlike comments, they can’t become stale because the compiler enforces their accuracy.
Easier Onboarding Link to heading
For anyone else working on these projects (including my future self), the type information provides crucial context about how functions should be used and what data structures look like.
Catching Edge Cases Link to heading
TypeScript’s strict null checking revealed several potential runtime errors where code assumed values would always be present:
// This would have been a runtime error waiting to happen
function processUser(user: User | null) {
// TypeScript error: Object is possibly 'null'
return user.name.toUpperCase()
// Fixed version
return user?.name?.toUpperCase() ?? "Unknown"
}
Challenges and Gotchas Link to heading
Third-Party Dependencies Link to heading
Not all NPM packages include TypeScript definitions, and the community-maintained @types packages aren’t always complete or up-to-date. I spent considerable time either writing my own type definitions or working around incomplete ones.
Any Type Temptation Link to heading
The any type is TypeScript’s escape hatch, and it’s tempting to overuse it when encountering complex migration challenges. Resisting this temptation and taking the time to properly type complex objects paid dividends in the long run.
Build Complexity Link to heading
Adding TypeScript increased build complexity, requiring compilation steps and source map configuration. However, tools like ts-node for development and tsc-watch for automatic recompilation made this manageable.
The Vue.js Migration Link to heading
Converting the Vue.js frontend presented unique challenges. The component props and computed properties needed careful typing, and complex composition functions required advanced TypeScript patterns.
interface Props {
items: Item[];
onItemSelect: (item: Item) => void;
loading?: boolean;
}
<script setup lang="ts">
import { defineProps } from 'vue';
const props = defineProps<Props>();
const handleItemClick = (item: Item) => {
props.onItemSelect(item);
};
</script>
The payoff was enormous; Vue components became much easier to understand and modify, and prop-passing errors were caught at compile time rather than runtime.
Performance Impact Link to heading
Contrary to some concerns, TypeScript compilation added minimal overhead to the development workflow. Modern tools like esbuild and swc have made TypeScript compilation extremely fast, and the IDE benefits more than compensate for any build time increases.
Testing Improvements Link to heading
TypeScript significantly improved the testing experience. Type-safe mock objects and better IDE support for test assertions reduced the time spent debugging test setup issues.
const mockUser: User = {
id: "123",
email: "test@example.com",
name: "Test User",
createdAt: new Date(),
}
Incremental Strictness Link to heading
As the codebase stabilised, I gradually enabled stricter TypeScript settings:
{
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true
}
Each of these settings revealed additional potential issues and improved code quality.
Key Learnings Link to heading
Start Small and Gradual Link to heading
The incremental approach was crucial. Attempting to convert everything at once would have been overwhelming and error-prone.
Invest in Type Definitions Link to heading
Taking time to properly define interfaces and types upfront pays dividends throughout the project lifecycle.
Use the Community Link to heading
The TypeScript community has created excellent resources, from type definitions to migration guides. Leveraging existing patterns and solutions saved significant time.
Don’t Fear the Compiler Link to heading
TypeScript’s error messages can seem intimidating initially, but they’re usually pointing to real issues that would cause runtime problems.
Looking Forward Link to heading
The TypeScript migration has fundamentally changed how I approach JavaScript development. The confidence that comes from type safety, combined with better tooling support, has made me significantly more productive.
For new projects, TypeScript is now my default choice. For existing JavaScript projects, the migration effort is worthwhile for any codebase that will continue to evolve and grow.
The time invested in learning TypeScript’s more advanced features; generics, conditional types, and utility types; has been worthwhile, but the 80/20 rule applies. Basic type annotations and interfaces provide most of the benefits.
Have you migrated JavaScript projects to TypeScript? What strategies worked best for your team, and what challenges did you encounter along the way?