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 | interface User { |
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
andPassword
being defined in terms ofString
(#).
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 | interface User { |
So the following code should be a compile error:
1 | const user: User; |
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 | declare class OpaqueTag<S extends string> { |
And the usage would be:
1 | type Email = Opaque<string, "Email">; |
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.