Opaque types in Typescript

Primitive Obsession

When modeling the entities in a software, we normally try to find the proper type of our domain models. For example, we might come up with the following model for User entity:

1
2
3
4
5
6
7
8
interface User {
email: string;
password: string;
username: string;
bio?: string;
age: number;
createdAt: Date;
}

This example, is similar to how we usually model User and as you can see, all the field types are primitives.

It is interesting to think why we use string for email but not for createdAt. Probably because Date was was already available in the language from day 0. That’s why we call them primitives. But why don’t we have Email or Username type? There a few benefits for defining types, even for such simple values:

  • We can define proper domain models and assign its logic to the type. For example, validating an email address is a part of the Email domain model.
  • We can refactor the code using these types easier. For example, you would write email validation logic one time and changing it will affect everywhere. Additionally, the tooling can help you finding all the usages of email when you try to refactor.
  • Different types for different domain models prevent wrong assignments. For example, it prevents the programmer to wrongly assign a username value to email field.

On the other hand, some of these domain models are extremely simple and defining a new type is cumbersome in most of the programming languages, but not in Typescript!

Primitive Obsession is more than just an issue with simple types, but in this article, I would like to show you how to leverage Typescript compiler to solve one of the primitive obsession code smells. You can read more about primitive obsession here.

Opaque types to the rescue

Opaque Types in Scala are an answer to this problem. One of the usecases is:

  • Classes representing different entities with the same underlying type, such as Id and Password being defined in terms of String (#).

And this is how it should be defined in Scala. Very simple!

1
opaque type Logarithm = Double

Flow also supports opaque types out of the box and the syntax is very similar to Scala:

1
opaque type ID = string;

Back to our example, we would like to change our User model to the following:

1
2
3
4
5
6
7
8
9
interface User {
email: Email;
password: HashedPassword;
token?: Token;
username: Username;
bio?: Bio;
age: Age;
createdAt: CreatedAt;
}

So the following code should be a compile error:

1
2
3
const user: User;

user.password = "myUser"; // This should be a compile error

In Typescript, it is possible to define a type that prevents wrong assignments, while still only holding a primitive at runtime. so we don’t break the serialization of the value:

1
2
3
4
5
declare class OpaqueTag<S extends string> {
private tag: S;
}

type Opaque<T, S extends string> = T & OpaqueTag<S>;

And the usage would be:

1
2
3
4
5
6
type Email = Opaque<string, "Email">;
type Username = Opaque<string, "Username">

const email: Email = "alisabzevari@gmail.com" as Email;

const username: Username = email; // This is a compile error

By using Opaque helper, we get the following benefits:

  • It is not possible to assign strings or other types to an opaque type variable. It will be a compile error.
  • String literals should be annotated with the correct type when assigning to an opaque type variable.
  • The variable holds just a string so there is no runtime overhead.
  • Serialization/Deserialization works out of the box.