Heap Blog

Goodbye CoffeeScript, Hello TypeScript

Web apps are becoming increasingly feature-rich, and the Heap frontend is no different. We expose an interface that lets users organize their data and build custom visualizations. Nearly every interaction changes an underlying model and there are subtle rules around how the UI behaves.

Our previous stack of Backbone and CoffeeScript wasn’t scaling well to a lot of common UI challenges, such as data syncing, subview management, and redrawable views. It was time to rethink how we could both improve maintainability and speed up future feature development.


It all starts with language

We wanted a language that would address 2 main concerns in our codebase:

  1. Code shouldn’t be hard to reason aboutWe had too many implicit object schemas, mutative operations on inputs, and implicit cross-view assumptions. This caused many hard-to-reason about bugs, and sometimes very unexpected behavior in production.
  2. Development should be fastWrite code => hopefully encounter runtime error when testing => repeat is inefficient both in speed and accuracy. Many common errors in code shouldn’t require a refresh of a test page and a mechanical series of clicks.

CoffeeScript

CoffeeScript was wildly popular in the early days of Heap and regarded as one of the most mature alternative JS languages. Here’s how it stacks up:

Pros:

  • Minimalistic syntax with lots of syntax sugar
  • Already well-known within team
  • Easy to map output JS back to source CoffeeScript (before source maps)

Cons:

  • Variable initialization and reassignment are the same

    It’s easy to accidentally overwrite a variable from a higher scope as a codebase increases in depth. This is really bad, because it limits our ability to write more complex code. Safely creating a variable requires pressing Ctrl + F and examining the current file to make sure there isn’t a conflict.

  • Existential operator accessor (?.) is a leaky abstraction for null/undefined

    This is often added to ‘make things work’, without understanding why it’s necessary. If we could document in a single place the potential ’emptiness’ of a value, reading related code would be much easier.

  • Ambiguous syntax

    It’s reasonable to assume foo bar and hello world will compile to either:

    • foo(bar) && hello(world)
    • foo(bar && hello(world))

    depending on what you’re hoping it’ll compile to. In the words of one teammate:

    This is the only language I’ve worked in where I need to compile my code and verify the output to make sure it does what I expect it to.

TypeScript

Given the shortcomings of CoffeeScript, we investigated many alternatives, including ClojureScript, Babel, Elm, and PureScript. TypeScript stood out as a clear winner:

Pros:

  • Built-in type annotations let you document interfaces and enforce their correctness at compile time, cutting down on logical errors
  • Already has several other tools built on it (e.g. Flow type annotations compiling down to TypeScript annotations, Angular 2.0)
  • Clear, maintained development roadmap with rapid releases
  • Community type definitions for adding types to third-party JavaScript

Cons:

  • Not yet 100% parity with ES6 (lagging behind Babel)
  • Some missing type system features: type bounds, half-baked local types, f-bounded polymorphism
  • Community type definitions are unversioned, so it’s difficult to find type annotations for older versions of libraries

Many other languages were disqualified for one or more of these reasons:

  • Overly complex external JavaScript workflow
  • Non-mainstream syntax
  • Small community – integrations may need to be self-written
  • Docs hard to navigate or find

Filling in the gaps

There are a few language features which we heavily missed in TypeScript:

Pattern matching is a common feature in functional languages that drastically reduces type casts, while encouraging consistent return types and discouraging mutation by directly returning an expression. We’ve built a small type-safe version of pattern matching in TypeScript and use it extensively throughout our codebase.

Implicit conversions are a safer form of monkey patching, a popular though frowned upon idiom in JavaScript. These exist in Ruby as refinements, and we can simulate them with a slightly more verbose syntax (inspiration heavily drawn from Scala). Here, we build a generic version of Immutable.js’ withMutations method:

While sometimes more verbose, consistent usage of these patterns leads to a codebase where nearly all bugs are logical errors, and not accidental organizational errors. This makes bugs easier to track down.


Uncaught TypeError: undefined is not a function

We had several bugs in our error tracker that were previously black holes – variables would take on a value of null or undefined, and it’d be nearly impossible to figure out why.

The easy solution: add more guarantees via types.

Options

Options declare whether something is allowed to be null/undefined. They completely solve the CoffeeScript issue of duplicating ?. operators throughout your codebase – you only need to declare the nullability of something once. After that, the compiler will warn you if you err in assuming that something won’t ever be null. This means a more reliable application and serves as living documentation that’ll auto-update as your application grows, decreasing ramp-up time into the codebase.

Since adopting these, we’ve eliminated the entire class of null/undefined errors. This is important because that class is among the hardest to track down, usually because the error occurrence and the error in logic end up far away from each other.

Trys

Trys declare whether something succeeded or failed. They’ve caused us to recognize and react to parsing errors when we try to convert data into client-side models. Parsing nested objects is easier as well, since we can compose parsers for each component of the aggregate object.

Futures

Futures (also known as Promises) declare whether an asynchronous operation succeeded or failed, and let you apply control flow to the result. This is far better than passing callbacks into other functions because logic around onComplete and onFail only needs to be implemented once, instead of for every single asynchronous function. We’ve chosen a more traditional choice in the form of when.js since it’s compliant with the A+ Promises spec, which promises better integration with other libraries.

Monapt is the open-source library we use throughout our codebase. Feel free to stop by gitter chat with any questions!

Language is just the beginning of creating a solid frontend codebase. The structure and organization of views can make an enormous difference as well. In part 2, we’ll explain the evolution of our view architecture, and lessons learned from React and Elm.

Let us know if you have any questions @heap! And if you’re interested in building robust interfaces that turn data into insight for thousands of people, shoot us a note at jobs@heapanalytics.com.

Jiawei Li

Your Header Sidebar area is currently empty. Hurry up and add some widgets.