Lee Byron

tilopaque types

One of my favorite features from Flow is opaque types. This allows a separation between interface and implementation that’s incredibly helpful as a type API designer, and a rare example of a “nominal type” in an otherwise “structural type” environment. Unfortunately, TypeScript still does not support this functionality.

  1. I use this as a much simpler version of private fields that doesn’t require a class interface and is scoped to a whole module rather than just a single class (fantastically helpful for a more functional programming style).

  2. This is a great way to generate “subtypes” of a primitive like a string or number, which is particularly useful for representing things like URLs, UUIDs, and other things which are string-like but not strings.

You can emulate this behavior in TypeScript by lying to the compiler. For example, let’s create a UUID type:

export type UUID = string & { [$uuid]: true }
declare const $uuid: unique symbol

export function isUUID(value: unknown): value is UUID {
  return uuid.validate(value)
}

export function createUUID(): UUID {
  return uuid.v4() as UUID
}

This introduces the type UUID which you can use anywhere you use a string, but you can also write functions that accept only a UUID and not just any string.

This works by telling TypeScript that there exists a variable called $uuid that is a unique symbol (the result of calling Symbol()) and that the type UUID is both a string and (&) an object with a required property of that unique symbol1. However none of this exists at runtime, there is no variable or unique symbol, so there’s no way of actually creating a UUID type outside of casting, which we only do in this bit of library code.

Perhaps you don’t actually want to expose that UUID is implemented as a string, just remove the string &:

export type UUID = { [$uuid]: true }

This version cannot be used where a string is expected, even though it’s still a string value at runtime.

This works surprisingly well, but there are shortcomings:

More examples of where opaque types are useful:

I’d still love to see explicit support for this long-loved feature from Flow built into TypeScript, but this technique works reasonably well despite the shortcomings.

  1. There are variants of this technique, a common alternative being {_brand: typeof $uuid} but I like this one the best for a couple reasons. I don’t like the IDE showing ._brand in the typeahead completion; it will not show a symbol property. I find it slightly more helpful to see the name of the symbol shown when encountering an error .