TypeScript has crossed the threshold from interesting to essential. Major frameworks support it natively. Developer tooling has improved dramatically. The question for most JavaScript projects isn’t whether to adopt TypeScript, but how.
Here’s how to migrate existing codebases incrementally.
Why TypeScript Now
Mature Ecosystem
TypeScript in 2019:
- All major frameworks have first-class support
- Editor support is excellent (VS Code, WebStorm)
- Community is large and active
- Type definitions exist for most libraries
Real Benefits
Catch bugs earlier:
// JavaScript - runtime error
function greet(user) {
return `Hello, ${user.name}`;
}
greet({ nam: "Alice" }); // Typo, fails at runtime
// TypeScript - compile-time error
interface User {
name: string;
}
function greet(user: User) {
return `Hello, ${user.name}`;
}
greet({ nam: "Alice" }); // Error: Object literal may only specify known properties
Better refactoring:
- Rename symbols across the codebase
- Find all references reliably
- Safe deletion of unused code
Self-documenting code:
- Types serve as documentation
- IDE shows types inline
- Less need for JSDoc
The Cost
Migration isn’t free:
- Learning curve
- Build pipeline changes
- Migration time
- Some ongoing overhead
But for projects with longevity, the investment pays off.
Migration Strategy
Incremental Over Big Bang
Don’t try to convert everything at once:
Week 1: Add TypeScript to build pipeline
Week 2: Convert utility functions
Week 3: Convert data models
Week 4: Convert a feature module
...
Week N: Remove all JavaScript files
Why incremental:
- Lower risk
- Continuous value delivery
- Team learns as they go
- Easier to course-correct
Loose to Strict
Start with permissive config, tighten over time:
// Phase 1: Get it compiling
{
"compilerOptions": {
"strict": false,
"noImplicitAny": false,
"strictNullChecks": false
}
}
// Phase 2: Enable some checks
{
"compilerOptions": {
"noImplicitAny": true,
"strictNullChecks": false
}
}
// Phase 3: Full strict mode
{
"compilerOptions": {
"strict": true
}
}
File-by-File Conversion
Rename .js to .ts, fix errors, repeat:
# Check how many files remain
find src -name "*.js" | wc -l
# Convert one file
mv src/utils/date.js src/utils/date.ts
npm run typecheck # Fix errors
Setup
Initial Configuration
// tsconfig.json
{
"compilerOptions": {
"target": "ES2018",
"module": "commonjs",
"lib": ["ES2018"],
"outDir": "./dist",
"rootDir": "./src",
"strict": false,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"allowJs": true, // Allow .js files
"checkJs": false // Don't type-check .js files yet
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
Key settings for migration:
allowJs: true- Mixed JS/TS codebasestrict: false- Start permissiveskipLibCheck: true- Faster builds, skip declaration files
Build Integration
Webpack:
module.exports = {
resolve: {
extensions: ['.ts', '.tsx', '.js', '.jsx']
},
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/
}
]
}
};
Node.js:
npm install ts-node typescript
npx ts-node src/index.ts
# Or compile first
npx tsc && node dist/index.js
Type Definitions
Install types for dependencies:
npm install --save-dev @types/node @types/express @types/lodash
For packages without types:
// src/types/untyped-package.d.ts
declare module 'untyped-package' {
export function doSomething(arg: string): void;
// Or if you don't want to type it fully:
// export = any;
}
Conversion Patterns
The any Escape Hatch
When you can’t figure out types immediately:
// Temporary - fix later
function processData(data: any): any {
// Complex logic that's hard to type right now
return data.map((item: any) => transform(item));
}
// TODO: Replace with proper types
Use any to make progress, then come back.
Type Inference
Let TypeScript infer when obvious:
// Unnecessary - TypeScript infers this
const name: string = "Alice";
const numbers: number[] = [1, 2, 3];
// Better - let inference work
const name = "Alice";
const numbers = [1, 2, 3];
// Explicit when needed
function getUser(id: string): User { // Return type helps readers
return users.find(u => u.id === id)!;
}
Interface vs Type
Both work, pick one pattern:
// Interface - extendable, good for objects
interface User {
id: string;
name: string;
}
interface AdminUser extends User {
permissions: string[];
}
// Type - good for unions, intersections
type Status = 'pending' | 'active' | 'closed';
type UserWithStatus = User & { status: Status };
Convention: interfaces for objects, types for everything else.
Handling Null/Undefined
With strictNullChecks:
// Error: Object is possibly undefined
function getLength(str?: string): number {
return str.length;
}
// Option 1: Guard clause
function getLength(str?: string): number {
if (!str) return 0;
return str.length;
}
// Option 2: Non-null assertion (when you know better)
function getLength(str?: string): number {
return str!.length; // ! asserts non-null
}
// Option 3: Optional chaining
function getLength(str?: string): number {
return str?.length ?? 0;
}
API Response Types
Type your API responses:
interface ApiResponse<T> {
data: T;
meta: {
page: number;
total: number;
};
}
interface User {
id: string;
name: string;
email: string;
}
async function fetchUsers(): Promise<ApiResponse<User[]>> {
const response = await fetch('/api/users');
return response.json();
}
React Components
interface Props {
user: User;
onUpdate: (user: User) => void;
isLoading?: boolean;
}
const UserCard: React.FC<Props> = ({ user, onUpdate, isLoading = false }) => {
// Component implementation
};
Express Handlers
import { Request, Response, NextFunction } from 'express';
interface CreateUserBody {
name: string;
email: string;
}
app.post('/users', (
req: Request<{}, {}, CreateUserBody>,
res: Response,
next: NextFunction
) => {
const { name, email } = req.body; // Typed!
});
Common Challenges
Third-Party Types Don’t Match
When @types package doesn’t match reality:
// Extend or override types
declare module 'problematic-library' {
interface Options {
newOption: boolean; // Missing in @types
}
}
Complex Generic Types
Start simple:
// Don't start here
type DeepPartial<T> = T extends object ? {
[P in keyof T]?: DeepPartial<T[P]>;
} : T;
// Start here
interface PartialUser {
id?: string;
name?: string;
}
Add complexity only when needed.
Large Files
Break down before converting:
// big-file.js (2000 lines) - hard to convert
// Split first, then convert
// utils/validation.ts
// utils/transformation.ts
// utils/formatting.ts
Tracking Progress
Metrics
# Count files by extension
find src -name "*.ts" | wc -l # TypeScript
find src -name "*.js" | wc -l # JavaScript
# Percentage complete
echo "scale=2; $(find src -name '*.ts' | wc -l) / \
$(find src \( -name '*.ts' -o -name '*.js' \) | wc -l) * 100" | bc
Strict Mode Coverage
Track which files pass strict mode:
// tsconfig.strict.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"strict": true
},
"include": ["src/utils/**/*", "src/models/**/*"] // Strict-ready files
}
Key Takeaways
- Migrate incrementally, not all at once
- Start with
strict: false, tighten over time - Use
anyas temporary escape hatch, then fix - Let TypeScript infer obvious types
- Type API responses and shared interfaces first
- Track progress with file counts
- Run type checking in CI to prevent regression
TypeScript migration is a marathon, not a sprint. Consistent progress beats perfect planning.