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:
- Code shouldn’t be hard to reason about We 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.
- Development should be fast
Write code => hopefully encounter runtime error when testing => repeatis 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 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:
- Minimalistic syntax with lots of syntax sugar
- Already well-known within team
- Easy to map output JS back to source CoffeeScript (before source maps)
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 + Fand examining the current file to make sure there isn’t a conflict.
Existential operator accessor (
?.) is a leaky abstraction for
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.
It’s reasonable to assume
foo bar and hello worldwill 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.
- 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
- 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:
- 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.
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
undefined, and it’d be nearly impossible to figure out why.
The easy solution: add more guarantees via types.
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 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 (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
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.
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 firstname.lastname@example.org.