Practical uses for abstract types in ReScript
Published: 2023-02-27 17:55:15

Abstract types are types with no content. They look like this in ReScript:
```rescript
type email
```
Just a type definition, but no content. The type is named `email`, so we can kind of guess it's a string, but there's no way to use it like a string, because it's _opaque_ to the type system - we can't know what's inside. 

Looks useless at a first glance? It has quite a large number of use cases. This post will cover a few of them where using abstract types can help you ensure better type safety throughout your application. 

Let's dive into an example!

## Tracking ID:s coming from an external API
Imagine we have an API from which we retrieve organizations and users. We also have an API for updating a user's email. The type definitions for users and organizations might look something like this:
```rescript
type user = {
  id: string,
  name: string,
  email: string,
}

type organization = {
  id: string,
  name: string,
  users: array<user>,
}
```

We've also made up a few API:s that we'll use for illustrative purposes:
```rescript
@val
external getCurrentOrganization: unit => promise<organization> =
  "getCurrentOrganization"

@val
external setUserEmail: (
  ~email: string,
  ~id: string,
) => promise<result<unit, string>> = "setUserEmail"

@val
external getMe: unit => user = "getMe"
```
The externals above binds to a few imaginary APIs: `getCurrentOrganization` for retrieving the current contextual organization, `setUserEmail` to set the email of a user via a user ID, and `getMe` to retrieve the current user (me). 

Application code for updating the current users email might look something like this:
```rescript
let me = getMe()
let currentOrganization = await getCurrentOrganization()
let {id, name} = currentOrganization

Console.log(`Trying to update email for "${me.name}", in context of organization "${name}".`)

switch await setUserEmail(~email="some.fine.email@somedomain.com", ~id=id) {
| Ok() => Console.log("Yay, email updated!")
| Error(message) => Console.error(message)
}
```
> This example uses [ReScript Core](https://github.com/rescript-association/rescript-core)

Excellent, this works well! Or wait... actually, it doesn't. Can you spot the error?

We're accidentally passing the _organization_ `id` to `setUserEmail`, not the user id (`me.id`). Shoot. At some point we destructured the `id` from `currentOrganization`, used it for something (probably logging), but then decided it wasn't necessary. But we forgot to remove it from the destructure, and now we're accidentally passing it to `setUserEmail`.

Solving this is obviously easy - just pass `me.id` instead. But what can we do to prevent this from happening again? 

An `id` is a `string`, that's how it comes back from the external API. Well, while it's true that it's a `string` at runtime, we can tell the _type system_ it's something else, and then have the compiler help us ensure the above cannot happen. 

Here's how we can do that.

### Leveraging abstract types to control how values are used through your application
Let's restructure our type definitions a bit:
```rescript
type userId
type organizationId

module Id = {
  type t<'innerId>
}

type user = {
  id: Id.t<userId>,
  name: string,
  email: string,
}

type organization = {
  id: Id.t<organizationId>,
  name: string,
  users: array<user>,
}
```

- We're adding two abstract types for the two types of ID:s we have, `userId` and `organizationId`.
- We're adding a _module_ called `Id`, with a single type `t<'innerId>`. This looks a bit weird, and we could've skipped this and just used `userId` and `organizationId` directly. But, this is important for convenience. You'll see why a bit later.
- We change the `id` fields for `user` and `organization` to be `Id.t<userId>` and `Id.t<organizationId>` respectively.

These changes are all at the *type level only*. Nothing changes at runtime - every `id` will still be a `string`. We're just changing what the type system sees as we compile our application.

As we try to compile this, we immediately catch the error we previously made:
```rescript
  39 ┆ switch await setUserEmail(~email="some.fine.email@somedomain.com", ~id=id) {

  This has type: Id.t<organizationId>
  Somewhere wanted: string

```
It's telling us that we're trying to pass an `Id.t<organizationId>` where a `string` is expected. That means two things to us:
1. We're catching the error we made previously - yay!
2. `setUserEmail` is still expecting a `string` for the user ID, but it should now expect our new `Id.t<userId>` instead.

Let's change the type definition for the email updating API to expect the correct ID type:
```rescript
@val
external setUserEmail: (~email: string, ~id: Id.t<userId>) => promise<result<unit, string>> = "setUserEmail"
```

There! Let's try to compile again:

```rescript
  39 ┆ switch await setUserEmail(~email="some.fine.email@somedomain.com", ~id=id) {

  This has type: Id.t<organizationId>
  Somewhere wanted: Id.t<userId>
  
  The incompatible parts:
    organizationId vs userId
```
Still doesn't compile obviously, but **look at that error message**. It's telling us _exactly_ what's wrong - we're passing an `organizationId` where we're supposed to pass a `userId`!

Fixing this is now a piece of cake:
```rescript
switch await setUserEmail(~email="some.fine.email@somedomain.com", ~id=me.id) {
```
...and it compiles!

Notice also that it's now impossible to pass anything else as `id` to `setEmailUser` than an `Id.t<userId>`. And the only way to get an `Id.t<userId>` is to call your external API retrieving a `user`. Pretty neat.

### Hiding underlying values with abstract types
Let's pause here for a moment. Remember that all changes we've made have been at the _type system level_. Each `id` is still just a `string` at runtime. Doing `Console.log(me.id)` would log a string. There's no such thing as a `Id.t` when the code actually runs. We're just instructing the type system to track every type of ID and ensure it can't be used in any place we don't specifically allow.

Also note that it's impossible to create an `Id.t` yourself, even if you technically know that it's really a `string`:
```rescript
let userId: Id.t<userId> = "mock-id"
```

This produces:
```rescript
  12 │ let userId: Id.t<userId> = "mock-id"

  This has type: string
  Somewhere wanted: Id.t<userId>
```

So, again. Only way to get an `Id.t<userId>` is to fetch a `user` from your API.

With just a few lines of code we've implemented a mechanism that can ensure that ID:s can't be used in any way we don't control, and that they can't be constructed in the application itself. 

### Accessing the underlying `string` of the ID:s
Moving on with the example, one question remain: Why have a module `Id`, when we could just use the abstract types `userId` and `organizationId` directly? The error messages would be at least as good, and it'd achieve the same effect. 

Let's extend our example with what to do when you _do_ need to access the underlying value of something abstract, and why having a module like the `Id` module we have is important for ergonomics.

Imagine it suddenly got important for us to log the exact organization and user `id` values before we attempt updating the email. We want our logging code to look like this:
```rescript
Console.log(
  `Trying to update email for user with id "${me.id}", in context of organization with id "${currentOrganization.id}".`,
)
```

But, this doesn't compile:
```rescript
  37 ┆ Console.log(
  38 ┆   `Trying to update email for user with id "${me.id}", in context of or
     ┆ ganization with id "${currentOrganization.id}".`,
  39 ┆ )
  40 ┆ 

  This has type: Id.t<userId>
  Somewhere wanted: string
```
Ahh right. We're using the type system to hide the underlying type, but that goes two ways. That also means we can't use the ID by itself where we expect a `string`.

What if we could hide the fact that it's a `string` to all of the application, but also have a way of turning any tracked id into a `string`? That should be safe, because we know that any id comes from the API directly, and is guaranteed to be a `string`.

Turns out we can, and it's easy!

Let's restructure our `Id` module a bit again:
```rescript
module Id: {
  type t<'innerId>
  let toString: t<_> => string
} = {
  type t<'innerId> = string
  let toString = t => t
}
```
Quite a few new things happening here. Let's break them down one by one:
1. We've added an inline _module signature_, by writing `module Id: { ... } = { ... }`. The signature says what we want _the outside_ to see when they look at this module. After it follows the actual implementation.
2. The signature has `t` as an abstract type, but the implementation aliases `t` to `string`. Uh-oh, aren't we leaking the underlying `string` now? We're not, because in the _signature_ `t` is still abstract, and that's what the outside sees.
3. We've added a `toString` function that takes an `Id.t` and returns a `string`, which is exactly what we needed. Two things to notice about this. First, the signature says it takes `Id.t<_>` and produces a `string`. `_` in this case means "I don't care about the inner type" - `toString` can be used on any `Id.t`, it doesn't matter what inner type we've said it is, it's still a `string` under the hood. Second, notice the difference between signature and implementation. The implementation just returns the id it was fed, since it knows it's really a `string`.

The gist of the above is this:
- The outside world doesn't know what an `Id.t` is (it's abstract), but it does know it can run `toString` on it to get a `string`, because the signature says so.
- The module itself knows that an `Id.t` is _really_ a `string`, and therefore that it's fine to just return itself when converting an `Id.t` to a `string`.

Now, with the `toString` function, we can convert the ID:s in the log to strings: 
```rescript
Console.log(
  `Trying to update email for user with id "${me.id->Id.toString}", in context of organization with id "${currentOrganization.id->Id.toString}".`,
)
```

There, it compiles! Note that this is only for going the _safe_ way of turning an `Id.t` into a string. There's still no way to create a potentially unsafe `Id.t` yourself in your application.

And that's it. We're now fully controlling how the ID:s are used throughout our application. Pretty neat.

## Wrapping up
I'm a big believer in the importance of ergonomics - if it's too much hassle to use, people just won't use it. That's why this being easy and straight forward in ReScript is important. This is functionality I use often, and very much so because it's easy to use. Just define an abstract type, put it where you need it, and have the compiler rule out entire classes of potentially erroneous behavior.

This post describes a scenario where I personally find reaching for abstract types very powerful. What use cases do you have where you use abstract types? Write in the comments!

Thank you for reading.