I've been holding out against TypeScript for a long time. In this business, we've all been burned by the framework-of-the-month club, and it's hard to know which technologies are worth investing time in. But with TS adoption rising dramatically in the past couple of years, it's become clear that TypeScript isn't just another front end fad. If you're still writing pure ES6 (like I was until recently), it's probably time to make the jump.
In this blog post, I'll share some tips and tricks I've picked up from the process of migrating a pure JavaScript project to TypeScript. Some of these will be common sense; others might leave you scratching your head a bit. I'll start with a few mental models to help you understand TypeScript, and then move on to the practical stuff.
Rethinking your relationship with JavaScript
Many developers (myself included) resist TypeScript because they're comfortable with JavaScript. We've finally wrapped our heads around ES6 modules, arrow functions, and Promises. Why complicate things?
Here's the thing: TypeScript doesn't replace JavaScript - it's a superset that simply adds optional static typing on top. Every valid JavaScript file is already a valid TypeScript file (with the .ts
extension). This means you can migrate gradually, file by file, without breaking anything.
// This is valid JavaScript
const add = (a, b) => a + b;
// This is the same thing in TypeScript
const add = (a: number, b: number): number => a + b;
The TypeScript version just adds type annotations that tell us what kinds of values we can pass and what kind of value we'll get back. The compiler will warn us if we try to use this function incorrectly, but the compiled JavaScript will be virtually identical.
TypeScript is C# for JavaScript developers
If you've ever worked with C# (or Java), TypeScript will feel strangely familiar. That's not an accident - TypeScript was created by Anders Hejlsberg, who also designed C#. Both languages approach type systems in a similar way, with interfaces, generics, and type inference.
When I was profiling a JS application recently, I found some code where a function sometimes received strings and other times received numbers. Now, to be clear - I actually like JavaScript's dynamic typing. If you're writing good code and you understand what you're doing, dynamic typing gives you tremendous flexibility and expressiveness. The bug wasn't because of JavaScript's type system - it was because "someone" *cough* wasn't checking their inputs properly.
If you're the kind of developer who prefers guardrails, TypeScript does offer a way to catch these issues at compile time:
// In vanilla JS, you'd handle this with runtime checks
function processValue(value) {
if (typeof value !== 'string') {
throw new Error('Expected string but got ' + typeof value);
}
return value.toLowerCase();
}
// In TypeScript, you can declare the expected type upfront
function processValue(value: string): string {
return value.toLowerCase();
}
If I try to call processValue(42)
, the TypeScript compiler will catch this error before runtime. Some developers find this preferable to writing runtime type checks, but it's really a matter of personal preference and project requirements.
Don't prematurely optimize your TypeScript
Just like with performance optimizations, it's best not to overthink your types when you're starting out. TypeScript has excellent type inference, so you don't always need to specify types explicitly.
// TypeScript can infer these types automatically
const name = "Nicola"; // string
const isTyping = true; // boolean
const skills = ["HTML", "CSS", "JavaScript", "TypeScript"]; // string[]
Only add explicit type annotations where TypeScript can't infer the type correctly, like function parameters or when initializing variables without a value:
// Type inference doesn't work here, so we add annotations
const fetchUserData = async (userId: string): Promise<User> => {
const response = await fetch(`/api/users/${userId}`);
return await response.json();
};
// Initializing without a value needs a type annotation
let selectedUser: User;
Use types for better documentation
One of the most powerful features of TypeScript is its type system. While interfaces are often presented as the primary way to define object shapes (and they work well as blueprints for classes), I've found that the type
keyword is equally useful and often more flexible.
type User = {
id: string;
name: string;
email: string;
role: 'admin' | 'user' | 'guest';
lastLogin?: Date; // The ? makes this property optional
}
function updateUser(user: User): void {
// TypeScript knows exactly what properties should exist on user
console.log(`Updating ${user.name} (${user.role})`);
}
You can use type
and interface
almost interchangeably in most situations, and the choice often comes down to personal preference. Here's the same example with an interface:
interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'user' | 'guest';
lastLogin?: Date;
}
There are some technical differences - interfaces can be extended and merged, while types can create unions and intersections. But for most use cases, either will work fine. I tend to prefer type
unless I'm creating an interface for a class.
Regardless of which syntax you choose, the type definitions serve as documentation that's always up-to-date because the compiler enforces it. If I try to pass an object that doesn't match the User
type, TypeScript will warn me.
Minimizing "any" in your code
The any
type in TypeScript effectively opts out of type checking. It's useful when migrating from JavaScript, but try to minimize its use.
// Not great - defeats the purpose of TypeScript
function processData(data: any): any {
return data.map(item => item.value);
}
// Better - we know data is an array of objects with a value property
function processData(data: { value: string }[]): string[] {
return data.map(item => item.value);
}
When I was profiling a large TypeScript codebase, I discovered that areas with a high density of any
types were disproportionately responsible for bugs. This makes sense - when you opt out of type checking, you're back to the JavaScript wild west.
Type assertions: use with caution
Type assertions (using as
) or angle brackets) is sometimes necessary, but use it sparingly. When you assert a type, you're essentially telling TypeScript, "Trust me, I know what I'm doing," which bypasses the type checker's safety net.
// Using type assertion with 'as'
const someElement = document.getElementById('my-element') as HTMLInputElement;
console.log(someElement.value); // TypeScript now allows accessing .value
// Alternative syntax (less common, doesn't work in JSX)
const someElement = <HTMLInputElement>document.getElementById('my-element');
console.log(someElement.value);
// A safer alternative using type guarding
const someElement = document.getElementById('my-element');
if (someElement instanceof HTMLInputElement) {
console.log(someElement.value); // TypeScript knows it's safe here
}
While the TypeScript docs call this "type assertion," many developers refer to it as "type casting" (from other languages like C# or Java), and the concepts are similar. The important thing is understanding what it does: it overrides TypeScript's type inference with your manual type specification.
Type guarding (using instanceof
, typeof
, or custom type predicates) is generally safer because it performs runtime checks, ensuring that the type actually matches at execution time.
Using the compiler effectively
The TypeScript compiler (tsc
) is highly configurable. Here are some flags I've found particularly useful:
{
"compilerOptions": {
"strict": true, // Enable all strict type checking options
"noImplicitAny": true, // Raise error on expressions with implied 'any' type
"strictNullChecks": true, // Make null and undefined their own types
"noUnusedLocals": true, // Report errors on unused locals
"noUnusedParameters": true // Report errors on unused parameters
}
}
When I first started with TypeScript, I had these set to false
to ease migration. As I got more comfortable, I enabled them one by one to tighten up the type checking.
Clear methods for migrating to TypeScript
There are three ways to migrate a JavaScript codebase to TypeScript: file-by-file, feature-by-feature, or the big bang approach. In most cases, file-by-file is the safest:
- Rename a
.js
file to.ts
- Fix any errors TypeScript finds
- Repeat for the next file
From my experience migrating an Express.js app, I'd advise starting with utility functions and working your way up to more complex features. Models and data structures are also good early candidates since they benefit greatly from interfaces.
Here's a simple example of migrating an Express route handler:
// Before (user.js)
const express = require('express');
const router = express.Router();
router.get('/:id', async (req, res) => {
try {
const user = await User.findById(req.params.id);
if (!user) return res.status(404).send('User not found');
res.json(user);
} catch (err) {
res.status(500).send('Server error');
}
});
module.exports = router;
// After (user.ts)
import express, { Request, Response } from 'express';
import { User, UserDocument } from '../models/User';
const router = express.Router();
router.get('/:id', async (req: Request, res: Response) => {
try {
const user: UserDocument | null = await User.findById(req.params.id);
if (!user) return res.status(404).send('User not found');
res.json(user);
} catch (err) {
res.status(500).send('Server error');
}
});
export default router;
The TypeScript version makes it clear what types of objects we're working with, which makes the code more self-documenting and helps catch errors earlier.
Conclusion
I hope you've enjoyed my whistle-stop tour of TypeScript for the stubborn JS developer. If you spot any errors or glaring omissions, please leave a comment below. Cheers!