Engineering

TypeScript’s quirks: How inconsistencies make the language more complex

TypeScript's quirks

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. 

1. Interfaces with excess properties

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:

interface Dog {
  breed: string
} 

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

function printDog(dog: Dog) {
    console.log("Dog: " + dog.breed)
}

Then it is okay to call the function like:

const ginger = {
    breed: "Airedale",
    age: 3
}
printDog(ginger)

> Dog:Airedale

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:

printDog({
    breed: "Airedale",
    age: 3
})

Argument of type '{ breed: string; age: number; }' is not assignable to parameter of type 'Dog'.
  Object literal may only specify known properties, and 'age' does not exist in type 'Dog'.

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. 

2. Classes (nominal typing)

In addition to defining types as interfaces, TypeScript also creates types for classes. For example I could have instead defined a Dog class like:

class Dog {
    breed: string
    constructor(breed: string) {
        this.breed = breed
    }
}

After defining that, TypeScript allows you to use the class name as a type so you could write a function like:

function printDog(dog: Dog) {
    console.log("Dog: " + dog.breed)
}

The interesting part comes in when you define other classes with the same properties. Say that we also have a Cat class like:

class Cat {
    breed: string
    constructor(breed: string) {
        this.breed = breed
    }
}

Surprisingly TypeScript will actually allow you to pass Cats to functions that expect Dogs:

const shasta = new Cat("Maine Coon")
printDog(shasta)
> Dog:Maine Coon

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.

While this philosophy is consistent (everything is structurally typed) it is also quite surprising. Unless you are an engineer already familiar with TypeScript, you’d probably expect everything assignable to the Dog type would either be created with new Dog or one of its subclasses. Type assignability based on class names is called nominal typing and is used by most other popular typed languages, such as Java as well as Flow (Facebook’s answer to JavaScript with types). If you want to learn more about nominal typing, the Flow docs have a great description.

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. 

3. Discriminated Unions

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 

function printCatOrDog(animal: Cat | Dog) {...}

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:

interface Dog {
    breed: "Airedale" | "Golden Retriever" | "Bulldog"
}

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:

  1. Types that have a common, singleton type property — the discriminant.
  2. 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:

interface Dog {
    kind: "dog"
    bark: string
}

interface Cat {
    kind: "cat"
    meow: string
}

type Animal = Cat | Dog

Now given an Animal, we can use the kind property to distinguish which one we have. For example

function animalNoise(animal: Animal): string {
    // In this scope animal has type Animal
    // TypeScript won't allow us to access bark or meow because this animal might not have them

    if (animal.kind == "dog") {
        // Now that we've inspected the kind property, TypeScript knows the animal is a Dog, so we can safely access bark
        return animal.bark
    } else {
        // Here the compiler has determined the animal must be a Cat
        return animal.meow
    }
}

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:

interface Dog {
    taxonomy: {
        species: "Canis familiaris"
    }
    bark: string
}

interface Cat {
    taxonomy: {
        species: "Felis catus"
    }
    meow: string
}

type Animal = Cat | Dog

function animalNoise(animal: Animal): string {
    if (animal.taxonomy.species == "Canis familiaris") {
        return animal.bark
Property 'bark' does not exist on type 'Dog | Cat'.
    } else {
        return animal.meow
Property 'meow' does not exist on type 'Dog | Cat'.
    }
}

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.

How did we get here?

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? 

Not having worked on the language at all, I can only guess. One major clue is the focus supporting migrations from JavaScript. TypeScript was designed to enable engineers to add types to their existing JavaScript code.

To accomplish this, the TypeScript team has iteratively added features to allow increasing amounts of real-world JavaScript code to become typesafe. Each new release has added more language features and tightened the type checker to catch more pieces of unsafe code.

Behind the scenes, I fear that each of these incremental improvements may have also allowed TypeScript to slowly grow in complexity. Real world JavaScript is inconsistent, messy, and complicated and I think that TypeScript may have gone too far to support it.

For instance, I suspect that discriminated unions were added to TypeScript to support existing JavaScript code that used this pattern. As we saw in Quirk 3, discriminated unions only were implemented for properties one level deep (likely because a fully featured implementation could be difficult to build and slow to type check). Discriminated unions enabled a wider class of JavaScript to be converted to TypeScript but at the cost of adding another set of inconsistencies to the language. Over time these little inconsistencies have added up to real complexity.

TypeScript has an explicit design goal around simplicity,

Goal 5: Produce a language that is composable and easy to reason about.

but I fear that this vision isn’t currently achieved.

Should you still use TypeScript?

If you care about a perfect, consistent type system, TypeScript probably isn’t for you (Reason seems like it could be good). But if you want to add some type safety to an existing JavaScript codebase while leveraging a large, thriving open source community, then TypeScript is still your best bet.

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. 

Would you recommend this article? Yes / No