I have been working with JavaScript for years, from the days of ES5 all the way until the language started getting good (and I still am). When I started with TypeScript, I was the senior engineer on a large React codebase that did not use TypeScript. We were a small team and our productivity depended on being able to ship features and fix bugs at a (very) fast pace.
I attributed our performance to not only my team’s experience with JavaScript and React, but also because the dynamic nature of the language paired well with the “move fast and break things” mentality we were espousing to meet (extremely) tight deadlines.
And break things we did. As time went on our codebase began to buckle under its own weight. Complexity was growing, and bugs were creeping in more often. It started to feel like we were chasing our tails by constantly fixing breakages that should have been caught long before code was ever deployed1. It was also becoming very difficult to onboard new team members.
Something had to give, so I finally decided to give TypeScript a shot. I was reluctant at first because our codebase was large, 100% JavaScript (and JSX), and I (naively) thought that migrating our codebase and dealing with types would slow us down and provide no real downstream benefits (the compiled app is the same!).
Ironically, just the opposite happened. As it turned out, using TypeScript allowed us to ship higher quality code (less bugs) at an even faster pace, with more confidence than ever.
What follows is a way you can ease TypeScript into an existing project without having to do a full rewrite all at once, even if you’ve never used TypeScript before. If you’re starting a new codebase and are on the fence, even better!
Take small bites
You don’t have to learn everything at first. TypeScript is a superset, which means it’s JavaScript with added features on top of it2. Any valid JavaScript code is technically valid TypeScript, so you don’t need to learn a whole lot of it up front. You can safely “learn as you go” and reap the benefits (increasingly) along the way.
It’s totally fine to have both .js/x and .ts/x files co-exist in a single project. In fact, once you have decided to move forward with TypeScript, a good rule of thumb is to write new code in TypeScript and migrate the rest systematically over time.
Simple union types
An easy place to start integrating TypeScript into an existing project is with union types. Where possible, use these in place of a fixed set of values. For example, here’s a simple object that represents a bank account:
const account = {
id: 1,
name: "My Bank Account",
type: "checking",
};
For the type
field, instead of using a generic string value (which is prone to typos that will not be caught until a bug is discovered at runtime), define the possible values with a union of potential values:
type AccountType = "checking" | "savings";
And while we’re at it, this is a good opportunity to create a new type for the account object as well:
type Account = {
id: number;
name: string;
type: AccountType;
};
const account: Account = {
id: 1,
name: "My Bank Account",
type: "checking",
};
For experienced JavaScript devs, it may be hard to see why this is useful, but what we have now is a type-safe Account
object that will allow your editor to give you better info and prevent you from making some common mistakes (such as typos) during development before the app is run (let alone deployed).
A common convention in JavaScript is to use another object to approximate an enum (more on these in a bit):
const AccountType = {
Checking: "checking",
Savings: "savings",
};
This looks very similar to a TypeScript enum (which can also be used instead of a union type), but there’s an important difference: a typo such as AccountType.Cheking
is still 100% valid code and won’t be caught until runtime (yet another bug). It’s JavaScript’s dynamism at its worst. Once you realize how simple mistakes like this are so easily prevented with TypeScript, you’ll be hooked (and this is barely scratching the surface).
Another benefit to (even simple) types is that your code becomes (somewhat) self-documenting. No longer is there a mental burden of having to remember (or look up) what are all the possible values of account.type
? or what fields does an account
have?. Despite having to write a tiny bit of extra code, you end up being way more efficient because all these little gains add up as you apply them across your codebase.
Note: I mentioned that you can use a TypeScript enum for the same purpose as the example, but I recommend sticking with simple union types for directly serialized values (like we’re doing here), and use enums only symbolically (which requires a separate structure to map the enum to serialized values—but that’s a topic for another time).
Utility functions and classes
The next place you can continue migrating your codebase are your internal libaries of utility/helper functions. For example:
// JavaScript
export function capitalizeString(str) {
if (!str || typeof str !== "string") return "";
return `${str.charAt(0)}${str.slice(1)}`;
}
Is changed to:
// TypeScript
export function capitalizeStr(str: string) {
return `${str.charAt(0)}${str.slice(1)}`;
}
Apart from removing the runtime type check, this example may seem superfluous, but this allows your editor to work for you by preventing incorrect types from being used with these functions. Because JavaScript has confusing behavior where there’s differing functionality for the same operations on some types (like string and number concatenation), this helps to prevent a wide range of subtle issues. You may even squash a few bugs during this exercise!
Having defined types for objects and array arguments as well as return values are particularly useful, and all of this gives you even more of the self-documenting and editor benefits that we observed with union types.
After you’ve had some practice migrating some functions to TypeScript, you can move forward with typing all the classes that are defined in your codebase. This is a good opportunity to take some time to learn the differences between object types, interfaces, and classes in TypeScript, so you can use the best tool for the job3.
API requests and responses
The next area of focus should be API requests that are made by your app.
Take some time to add types for all of your request functions and responses. You’ll likely need to use external resources (like API docs) to get this done, but it’s worth getting these right since the “source of truth” for both requests and responses are likely defined outside of your application, unlike other types that are restricted to your codebase.
Here’s a quick example of a function that makes a network request to retrieve users:
const getUsers = async () => {
const response = await fetch("/api/users");
if (!response.ok) {
throw new Error(response.statusText);
}
const users = await response.json();
return users;
};
Unless you utilize JSDoc, a detailed comment, or external API docs, there’s no good way of knowing what a “user” looks like without using console.log
or a debugger, which eats up a lot of time in the long run. There’s also no safety from using the object incorrectly when relying solely on docs and comments.
This is what that same function could look like in TypeScript:
type User = {
id: number;
name: string;
email: string;
}
const getUsers = async (): Promise<User[]> => {
const response = await fetch("/api/users");
if (!response.ok) {
throw new Error(response.statusText);
}
const users = await response.json() as any as User[];
return user;
};
Assuming the type for User
is correct (based on API docs or backend code), unless and until the structure changes, you now have full visibility of not only the request function, but anywhere that a User
is accessed in your codebase without having to rely on external resources. This is powerful because it saves a ton of time and makes you more confident about your code—you will know if you are using a User
instance correctly, not just 99% sure you are.
Third-party libraries
The next area to tackle will be the usage of third-party libraries or frameworks. This includes things like React component props, hooks, etc. or anything else where the types are not defined in your codebase locally.
Fortunately, most packages these days have TypeScript definitions included, or have a dedicated @types package to provide definitions. For the latter, these need to be installed as a development dependency in your project:
npm i -D @types/example-package
In the rare cases where the module doesn’t include TypeScript definitions, and there’s no associated @types package, you can add a definition file to your project’s types
directory with the following contents (sans comment):
// File (extension is important):
// {project}/types/example-package.d.ts
declare module "example-package";
You won’t get a type information for these packages, but you can add types manually to this file if you want (otherwise you’ll be restricted to using any
for code that uses this package).
The “any” escape hatch
TypeScript gives you a way to work around its type system via the any
type that’s basically like saying: ignore TypeScript and behave just like JS here. It may be tempting to use this then when time is tight, you’re feeling particularly lazy, or if code just isn’t behaving as expected in a more type-strict environment—but don’t give in!
While there are some legitimate cases for using any
, you should carefully evaluate every usage of it because chances are, there’s a better way to accomplish what you’re aiming to achieve with types rather than turn TypeScript off altogether for the scenario in question. Even if it requires a bit of refactoring, you’ll likely end up with a more robust solution in the end.
Next steps
Even if you stopped here, that’s enough to gain significant benefits from TypeScript. To go further, I recommend learning more from the official TypeScript handbook to see where else the language can benefit your codebase, and to make sure you’re following the latest best practices.
This was a very high-level overview that’s light on actual implementation details (despite being 2,000 words!), but I plan on digging deeper into individual topics in the near future, so stay tuned.
At this point, if you’re still not convinced to give TypeScript a shot, your codebase likely isn’t complex enough for you to feel the pain, but trust me, at some point you will (especially if you’re working with a team). Linters and unit testing (both of which you should utilize) will only take you so far.
Overall, TypeScript has been one of my favorite technologies to come out of the JavaScript ecosystem, and it only takes a minimal amount of learning to gain some seriously significant benefits. For me, the thought of not using TypeScript for any real JS project is like riding in a car without a seatbelt—it just feels wrong and unsafe.
We had unit tests, but it was difficult to maintain 100% coverage, the tests themselves varied in quality, had their own bugs, and were even pushed aside altogether during times when it was more important to ship (this is bad practice, but I’ll leave the topic of unit testing for another time). ↩︎
I’ve seen people say that TypeScript should not have inherited JavaScript’s warts and have been a clean break, but I don’t think it would have been adopted as widely. One of the benefits of TypeScript is that you do not have to rewrite your existing codebase in a completely separate language all at once. If TypeScript was not a superset, we woudn’t get that (huge) benefit. I think they took the right approach here. ↩︎
This is beyond the scope of this article, but I plan on publishing something more in-depth on the topic in the near future. ↩︎