Modernizing Our Frontend with TypeScript

When we wrote the first version of the OneSignal front-end, our focus was to quickly build key functionality for our fast-growing user base. Since then, we have grown to delivering over two billion notifications a day, offering 12 first-party SDKs, and supporting both email delivery and all push notification channels. As our company has grown, our front-end focus broadened from building features as fast as possible, to also making sure our code was scalable and maintainable for our engineering team.

For OneSignal customers, the dashboard is front and center to their experience with our product. Originally, the dashboard was built with Embedded Ruby templates, jQuery, CoffeeScript, and a Bootstrap template. This allowed us to quickly build a powerful interface to tackle our customers’ most pressing push notification needs.

Over time, the product grew to support many more features including the management of audience segments, creation of notification templates, A/B test scenarios, automation workflows, and email composition. As we built more features into the product, our codebase grew quickly and each one took more diligence and time to support.

To help us stay on top of this challenge, we chose to adopt TypeScript. TypeScript has proven itself to be both a fast language to program in, and a huge asset for keeping our growing codebase maintainable.

Deciding on TypeScript

The first version of the dashboard was written in CoffeeScript. It leveraged jQuery to make asynchronous requests and to add interactivity to our views. At the time, it provided a nice productivity boost and a similar developer experience to working with our Ruby back-end. Since then, the JavaScript ecosystem has evolved to include a dizzying number of languages that compile to JavaScript. We were looking for a language that met the following requirements:

  1. Static typing, to assert correctness of code and provide safety when refactoring.
  2. Ease of learning, to help new team members contribute quickly.
  3. A strong ecosystem, to allow us to quickly integrate with existing JavaScript packages and good tools.

These criteria narrowed our choices down to TypeScript and Flow. While both have their own merits, we chose TypeScript because part of our team had already begun using it in the Web Push SDK with a lot of success.

Correctness

One benefit of using TypeScript is having a transpiler that moves a whole class of bugs from runtime to transpile time. This allows us to catch typos and attempts at accessing object properties that may not exist.

Typescript also allows for making assertions about the type of objects that are passed to a function or class. For example, here we create a simple function to add together two numbers in JavaScript:

function addNumbers(x, y) {  
  return x + y;
}

Now what happens if we were to pass this function a string by accident?

addNumbers(5, "0");  

This function will return “50” instead of 5. Let’s explore how we could prevent this using TypeScript.

function addNumbers(x: number, y: number) {  
  return x + y;
}

Above, we have the same function except the parameters are restricted to type number. Now when we call addNumbers(5, "0") TypeScript will raise this error:

TS2345: Argument of type '"0"' is not assignable to parameter of type 'number'.  

This gives us a chance to fix the call to add numbers before this code goes live.

Now say we were to export addNumbers() as a utility for other JavaScript projects. We would then no longer have control over the parameters that will be passed in. For this we can now use our type system to define the expected interface of our utility.

function addNumbers(x: Object, y: Object): number {  
  return x + y;
}

We declared the parameters to be of type Object (which is the type common to all JavaScript objects) and we have restricted the return type to number. Now we will receive the following error:

TS2365: Operator '+' cannot be applied to types 'Object' and 'Object'.  

Which lets us know that the logic of the function must be changed to meet the new interface. We do this by converting our parameters to numbers. Giving us the following code:

function addNumbers(x: Object, y: Object): number {  
  return Number(x) + Number(y);
}

Now when we call addNumbers(5, "0") we will get the expected return value of 5. By defining our interface before editing our logic, we were able use TypeScript to help us arrive at the desired solution. With the added benefit of asserting addNumbers() correctness with no added runtime cost.

Refactoring

Working with a strongly typed language provides us with the tools to refactor quickly and safely. Let’s take a look at a function button(), used to generate the markup for a button:

interface Button {  
  // Use TypeScript's union type to only allow valid styles
  type: "primary" | "secondary";
  text: string;
}
function button(options: Button) {  
  return `
    <button class="${options.type}">
      ${options.text}
    </button>
  `;
}

Say we receive a request to update all buttons to include icon that visually reinforces the action the user is about to take. To do this, we add the icon as a required parameter and add the necessary functionality to button().

interface Button {  
  type: "primary" | "secondary";
  text: string;
  icon: "edit" | "copy" | "open" | "save";
}
function button(options: Button) {  
  return `
    <button class="${options.type} icon-${icon}">
      ${options.text}
    </button>
  `;
}

After making this change, TypeScript will throw an error for every call to button() similar to the one below:

app/views/messages/ab-test/index.ts:100:19  
TS2345: Argument of type '{ type: "primary"; text: string; }' is not assignable to parameter of type 'Button'.  
        Property 'icon' is missing in type '{ type: "primary"; text: string; }'.

Having the compiler throw an error allows us to easily find all the now invalid calls, without having to manually retest every place on the site that uses this function. If we did not have this tool, we we would have to resort to methods like find and replace, which could easily miss cases or replace things we didn't intend to.

Self Documenting

One of the joys of working in a TypeScript codebase is the self documenting nature of a typed language. By glancing at a interface definition or function declaration, we are able to easily determine what is expected to be passed, and what we should get back. This is especially useful when working with code written by multiple developers, as expectations can be understood without reading the entire body of a file.

Superset of JavaScript

Since TypeScript is an extension of JavaScript, it integrates easily with existing packages. And with the help of DefinitelyTyped we are able to interact with these packages as if they were written in TypeScript.

Furthermore, all JavaScript source files can also be understood by the TypeScript transpiler, making it simple to incrementally migrate JavaScript sources files into TypeScript.

Summary

TypeScript has helped us maintain our rapid pace of development while significantly scaling our product and engineering team. Through features like transpile-time error checking and strong typing, we are able to both easily refactor our code and prevent many classes of bugs.

If you’re starting a project today, we highly recommend starting with TypeScript. Or, if you’re looking to modernize an existing project, TypeScript makes it easy to incrementally adopt the language without having to re-write all of your code.

If you’re interested in using TypeScript at work, we’re hiring frontend developers to join our team in the San Francisco Bay Area.