Jay's blog

The Two Most Popular Missing Typescript Features

Original art, the suspense is terrible, I hope it'll last

There are two features I think should be in Typescript that are currently missing. It's not just my opinion, either. When I was researching this blog post, I discovered that the tickets requesting these two features are the most discussed tickets with the "suggestion" tag still open. If I could wave a magic wand to change Typescript in any way, I'd add these two features.

#13219 throws clause and typed catch clause

This missing feature came up in discussion on a pull request of mine at work this week. A particular function had a return type of Promise<boolean> and included a try...catch statement in its body. A friend and colleague1 left a comment asking why the possible error types weren't represented in the return type of the function. Maybe something like Promise<boolean, AppException>?

The answer is that the Promise type doesn't support a second type variable to describe the value returned on rejection of a promise.

As I suspected, the lack of a rejection type mirrors how caught error types are handled. (Or should I say, not handled?)

try {
  // Code that might throw
} catch (e) {
  // What is the type of `e`?
}

Depending on the version of Typescript you're using and how you have configured it, the type of e in the code above is either any or unknown.

That's pretty unsatisfying to me, and it leads to a lot of boilerplate in every catch block where I have to write a bunch of if blocks to detect the type of e and handle it appropriately. And I still have to cover my ass in case I missed a type. I have no way of being absolutely sure I covered everything that might have been thrown.

JavaScript is particularly annoying when it comes to throw-ing things because almost any value can be thrown, not just sub-types of Error. You can throw strings, numbers, null. Almost anything your heart desires.2

I'd prefer it if Typescript could tell me all the possible types that might be thrown by a particular function or block of code. And I'm not alone. 1,349 people have given the 👍 reaction to microsoft/Typescript#13219.

The syntax suggested mirrors a feature already present in crusty, old Java.

 public static void fetchFile() throws IOException, NullPointerException {
    // code that can throw IOException

    // code that can throw NullPointerException
}

The big upshot of this feature in Java, besides your tools being able to warn you when you're not handling something your code might throw, is it enables the following type of try...catch syntax.

try {
    fetchFile();
} catch (IOException ex1) {
    // handle IOException appropriately
} catch (NullPointerException ex2) {
    // handle NullPointerException appropriately
}

I vastly prefer this syntax over a single catch block containing repeated if...else blocks with e instanceof AppError conditions to detect each type. It's important to note that microsoft/Typescript#13219 does not include this new try...catch syntax. I couldn't find a ticket requesting such a feature. But adding the throws annotation to function signatures might set the stage for it.

When might we see the throws clause in Typescript? I wouldn't hold my breath. This ticket has been open for 7 years! But a boy can dream, can't he?

#202 Nominal types

Typescript has a structural type system. What does this mean?

interface Car {
  name: string;
  age: number;
}

interface Dog {
  name: string;
  age: number;
  breed: string;
}

function announceCar(car: Car) {
  console.log(`The ${car.name} is a ${car.age} year old car.`);
}

const dodgeViper: Car = {
  name: 'Dodge Viper',
  age: 31,
};

announceCar(dodgeViper);
// The Dodge Viper is a 31 year old car.

const brunoTheDog: Dog = {
  name: 'Bruno',
  age: 5,
  breed: 'terrier',
};

announceCar(brunoTheDog); // <- Typescript is a-okay with this.
// The Bruno is a 5 year old car.

So, uh... that was weird. I passed a Dog value to the announceCar function, which says it accepts a Car value. And Typescript was fine with it.

That's because Dog is structurally compatible with Car. Dog has the same structural properties as Car, plus a breed key. Anything that expects a Car can also accept a Dog. A Car can't pass as a Dog, though, because Car is missing the breed key.

Compare that to C, which has a nominal type system.

#include <stdio.h>

typedef struct {
  char *name;
  int age;
} Car;

typedef struct {
  char *name;
  int age;
} Dog;

void announce_car(Car *car) {
  printf("The %s is a %d year old car.\n", car->name, car->age);
}

int main() {
  Car dodge_viper = {
    .name="Dodge Viper",
    .age=31
  };

  announce_car(&dodge_viper);
  // The Dodge Viper is a 31 year old car.

  Dog bruno = {
    .name="Bruno",
    .age=5
  };

  announce_car(&bruno);
  /*
   * main.c:31:16: error: incompatible pointer types passing 'Dog *' to parameter of type 'Car *' [-Werror,-Wincompatible-pointer-types]
   *   announce_car(&bruno);
   *                ^~~~~~
   * main.c:13:24: note: passing argument to parameter 'car' here
   * void announce_car(Car *car) {
   *                        ^
   */

  return 0;
}

This C program is almost identical to the Typescript code, except that I've made the Car and Dog types structurally identical to one another. C does not let me pass a Dog to the announce_car function.3 If I really wanted to pass a Dog to announce_car in a nominally typed language, I'd have to cast it to a Car or manually create a new Car with the values from my Dog.

IMHO, unless you already understand the difference between structural typing and nominal typing, microsoft/Typescript#202 doesn't do a very good job explaining it. But the feature being requested is the ability to opt into nominal typing for a given type. If I could specify that Car is a nominal type and a function expects a Car, you'd need to either give it a Car or cast the value you're passing into a Car first.

But... why? Why do people want this feature so badly?

Let me give you the most concrete example I've seen for why nominal typing could be so helpful.

type CarID = number;

interface Car {
    id: CarID;
    name: string;
    age: number;
}

type DogID = number;

interface Dog {
    id: DogID;
    name: string;
    age: number;
}

function loadCar(id: CarID): Car {
    // Loads a Car from the database

    return {} as Car; // Pretend this actually works
}

function updateCarName(id: CarID, name: string): Car {
    // Sets a Car's name in the database

    return {} as Car; // Pretend this actually works
}

function loadDog(id: DogID): Dog {
    // Loads a Dog from the database

    return {} as Dog; // Pretend this actually works
}

let bruno = loadDog(1);

updateCarName(bruno.id, "Chevy Bel Air");

The problem is in the last line. I'm passing a DogID to a function that expects a CarID. This is incorrect and can cause serious data corruption in my database. Typescript doesn't flag this as incorrect currently. Because Typescript's type system is structural, type CarID = number; causes CarID to be nothing more than a synonym for number.

Although CarID and DogID are technically both numbers, they are not semantically identical. If they could be declared as nominal types, Typescript would flag my attempt to pass bruno.id to updateCarName as a type error.

If I was really, really sure that I was doing the right thing, I could make Typescript accept it by casting, like this:

updateCarName(bruno.id as CarID, "Chevy Bel Air");

This may seem like an odd concept, having explicit nominal types in an otherwise structural type system. I tried searching for prior art, but I didn't really find anything that works quite like what's being proposed in microsoft/Typescript#202. In Elm, you can achieve a similar effect using algebraic data types. Typescript doesn't support algebraic data types and it likely never will as that's a whole different beast.4 Flow treats classes as nominal types, but that's all. It's not something you can opt into for basic types like numbers.

If you read through the microsoft/Typescript#202 ticket, you'll see plenty of people's attempts to achieve the desired effect using Typescript as it exists today. There are also plenty of options available on NPM. The general technique goes by many names: opaque types, branded types, tagged types, flavored types, etc. There are multiple variations on the theme that have their own advantages and drawbacks. While the more mainstream implementations are serviceable, I'd wager the concept would be easier to work with as a first-class feature of the language.5

How does this feature request compare to the throws feature request from the first half of this blog post? It has fewer 👍 reactions, but more discussion. As the issue number implies, it's been around longer. A whole 9 years! Looking at the request honestly, I think nominal types would be less impactful for the average developer than the throws feature. But it would still be a nice addition because it could help eliminate an entire class of bugs if used diligently.


  1. Shout out to Emily! Thanks for the great question and the inspiration for this blog post. ❤️

  2. Interestingly, you can't throw void. Thank goodness for small favors, I guess.

  3. Well, it would let me pass a Dog to announce_car if I hadn't compiled it with the -Werror flag. In that case, the compiler emits the same message as a warning and lets me do it anyway, which does work. But it only works because the types are literally the same. If I add a breed field to Dog after name but before age and set breed to "terrier", announce_car will emit "The Bruno is a 4195924 year old car." But that's beyond the scope of the point I'm trying to make here.

  4. FWIW, I love algebraic data types. I think they're more expressive than what Typescript currently has. But I don't think ADTs can map cleanly onto JavaScript's type system, meaning it's incompatible with Typescript's design goals.

  5. The way most opaque type implementations work amounts to lying to the type system. Take the implementation from ts-essentials for example. The implementation is only 9 lines long. It's got some interesting conditional types in there. I'm not exactly sure what bug it's trying to protect against. At the end of the day, the actual, literal type of Opaque<number, 'CarID'> would be number & { [__OPAQUE_TYPE__]: 'CarID' }. But that's a lie. Numeric literals are "turned into" the opaque type via assertion: 5 as CarID. There is no __OPAQUE_TYPE__ on the value. (1 as UserID)[__OPAQUE_TYPE__] is valid according to the compiler. The ts-essentials implementation prevents us from being able to call its bluff by declaring __OPAQUE_TYPE__ as a unique symbol that's never actually created or given a value, much less exported, so it's impossible to actually access that value since there's no way to access it. Try it in the playground to see what I mean. I've copied and pasted the code from ts-essentials. It compiles fine but throws an error at runtime.

#programming language theory #typescript