The engineers at Asana have been TypeScript fans from very early on. We started using TypeScript version 0.9.1 in 2013, blogged about it in 2014, wrote the first TypeScript bindings for React, and today have over 10,000 TypeScript files in our codebase. All of our new web frontend code is written in TypeScript and every product engineer at Asana learns it quickly upon joining the team.
We’ve learned a lot about where TypeScript shines and where it struggles. We love its powerful structural typing, popularity in open source projects, predictable output, and ease of adoption.
On the other hand, I’ve become aware of a lesser known problem in the language: TypeScript’s quirks and edge cases create a lot of confusion. TypeScript has a large number of special cases and surprises in the compiler that leave engineers scratching their heads and baffled. While individually these behaviors aren’t super damaging, as a whole they can make it more difficult for new engineers to form a mental model around the language and gain mastery.
Here are three of my favorite TypeScript surprises that seem to continually baffle engineers new to the language.
At the core of TypeScript are object interfaces. These are types that represent objects with certain properties on them. For example dogs can be modeled as:
This says that Dogs are objects that have a breed property that is a string. TypeScript is a structurally typed language. This means that to create a Dog you don’t need to explicitly extend the Dog interface. Instead any object with a breed property that is of type string can be used as a Dog.
One basic question you might ask is “Can Dogs have additional properties aside from breed?”. Unfortunately the TypeScript answer to this is complicated.
Say that we have a function
Then it is okay to call the function like:
TypeScript understands that ginger has 2 properties, including the required breed property, so it happily considers ginger to be a Dog and compiles without a problem. From this example it would be reasonable to conclude that TypeScript allows excess properties.
On the other hand, look what happens when we define ginger inline:
What happened here? TypeScript takes the stance that interfaces are not strict; they can contain excess properties. At the same time, TypeScript endeavours to catch bugs where there are typos in property names or extra property names that do nothing. In the second example, TypeScript realized that even though the argument does match the Dog interface, it’s probably not a useful thing to pass the age property into the printDog function and almost certainly represents a mistake. For more information on TypeScript’s stance here, see the documentation on Excess Property Checks. You can see this behavior live in the TypeScript playground.
While I think that the TypeScript stance here isn’t wrong (most excess properties are in fact bugs!) it does make the language more complex. Engineers can’t just think of interfaces as “objects that have exactly a set of properties” or “objects that have at least a set of properties”. They have to consider that inline object arguments receive an additional level of validation that doesn’t apply when they’re passed as variables.
In addition to defining types as interfaces, TypeScript also creates types for classes. For example I could have instead defined a Dog class like:
After defining that, TypeScript allows you to use the class name as a type so you could write a function like:
The interesting part comes in when you define other classes with the same properties. Say that we also have a Cat class like:
Surprisingly TypeScript will actually allow you to pass Cats to functions that expect Dogs:
The logic here is that because TypeScript is structurally typed, it only cares about the properties that objects contain (not how they were constructed). In this case that means that Dogs are anything with a breed property even if they were made with new Cat. Here’s the example in the TypeScript playground.
TypeScript’s roadmap has contained an item to investigate nominal typing support for some time and I hope that it gets prioritized soon because until then, this remains another quirk of the language that every engineer will get bitten by eventually.
In the meantime, if you want to ensure that all Dogs are made with new Dog, you must resort to adding magical hidden properties or other type hacks. There are some good examples in TypeScript Deep Dive and Nominal typing techniques in TypeScript. We’ve put these to use in our code at Asana, but I don’t feel very good about them.
My last example of surprising TypeScript behavior requires a few more concepts about the type system that we haven’t covered yet.
A union type is a type like Cat | Dog that represents values that can be either a Cat or a Dog. This allows you to create typesafe functions like
A string literal type is a type that matches a single string. So in addition to "abc" representing a string it can also refer to a type that matches exactly that string as well. These are particularly useful in conjunction with union types. For example we could have modeled the breed property on Dog like:
This is pretty nifty because it prevents typos that could happen if we modeled breed as a string.
Combining these together, TypeScript has a feature called discriminated unions. Quoting from the docs, there are 2 requirements for discriminated unions:
Types that have a common, singleton type property — the discriminant.
A type alias that takes the union of those types — the union.
Once you have these requirements, TypeScript will allow you to easily distinguish elements in the union when you check the property.
To make a concrete example, say that we want to model animals which can have a kind of "cat" or "dog" and each has different properties:
Now given an Animal, we can use the kind property to distinguish which one we have. For example
This “type narrowing” is a pretty amazing feature, but if you are coming from languages without this type of feature, it can be pretty hard to wrap your head around. I encourage you to view this example in the TypeScript Playground and hover over the various usages of animal to see how its type changes in different scopes. Discriminated unions are important because they provide a way to determine the type of an object based on a runtime check which isn’t easy to do because types only exist at compile time.
While quite powerful, discriminated unions are also distressingly specific. The compiler is only willing to apply this special treatment under some very specific circumstances. For example, say that we want to instead model animals based on their species in a nested object:
Here the discrimination fails! Despite the fact that we are checking the species and that property can only take on 2 possible values, the compiler refuses to narrow the type of animal in the two branches of animalNoise! Here it is in the playground.
How did this happen? Discriminated unions only apply to properties on the top level on an object. The fact that we nested the information one level deeper meant that the TypeScript compiler was unable to notice that it could refine the type, so we are stuck with a surprising compilation error. Yuck!
Once again, I’ve seen too many engineers attempt to write code similar to the second example and been surprised it didn’t work.
TypeScript is a language that is extremely popular and powerful, but also appears to have a lot of surprising behaviors and special cases. How did this happen?
TypeScript has an explicit design goal around simplicity,
but I fear that this vision isn’t currently achieved.
If you decide to use TypeScript, expect engineers on your team to have a good amount of head-scratching and wasted time reasoning about the compiler, but not nearly so much as to offset the amazing value that static types provide.
Longer term I hope that the TypeScript team will work to simplify and tighten up the language. They have recently fixed some of the worst sources of confusion (with –strictFunctionTypes for instance) but reshaping TypeScript into a simple, consistent language will take a concerted effort.