DEV Community

Gabriel Nordeborn
Gabriel Nordeborn

Posted on

Practical uses for abstract types in ReScript

Abstract types are types with no content. They look like this in ReScript:

type email
Enter fullscreen mode Exit fullscreen mode

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:

type user = {
  id: string,
  name: string,
  email: string,
}

type organization = {
  id: string,
  name: string,
  users: array<user>,
}
Enter fullscreen mode Exit fullscreen mode

We've also made up a few API:s that we'll use for illustrative purposes:

@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"
Enter fullscreen mode Exit fullscreen mode

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:

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)
}
Enter fullscreen mode Exit fullscreen mode

This example uses 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:

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>,
}
Enter fullscreen mode Exit fullscreen mode
  • 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:

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

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

Enter fullscreen mode Exit fullscreen mode

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:

@val
external setUserEmail: (~email: string, ~id: Id.t<userId>) => promise<result<unit, string>> = "setUserEmail"
Enter fullscreen mode Exit fullscreen mode

There! Let's try to compile again:

  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
Enter fullscreen mode Exit fullscreen mode

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:

switch await setUserEmail(~email="some.fine.email@somedomain.com", ~id=me.id) {
Enter fullscreen mode Exit fullscreen mode

...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:

let userId: Id.t<userId> = "mock-id"
Enter fullscreen mode Exit fullscreen mode

This produces:

  12  let userId: Id.t<userId> = "mock-id"

  This has type: string
  Somewhere wanted: Id.t<userId>
Enter fullscreen mode Exit fullscreen mode

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:

Console.log(
  `Trying to update email for user with id "${me.id}", in context of organization with id "${currentOrganization.id}".`,
)
Enter fullscreen mode Exit fullscreen mode

But, this doesn't compile:

  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
Enter fullscreen mode Exit fullscreen mode

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:

module Id: {
  type t<'innerId>
  let toString: t<_> => string
} = {
  type t<'innerId> = string
  let toString = t => t
}
Enter fullscreen mode Exit fullscreen mode

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:

Console.log(
  `Trying to update email for user with id "${me.id->Id.toString}", in context of organization with id "${currentOrganization.id->Id.toString}".`,
)
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (4)

Collapse
 
fhammerschmidt profile image
Florian Hammerschmidt • Edited

Great post. I really like when the type system prevents from doing such mistakes, because it's at the earlist possible stage.

Personally, I still use string for all kinds of IDs with the limitation that it must be named when passed (i.e. a labeled argument or a record field instead of a tuple field). It never happened that some dev used the wrong kind of ID.

And the reason is that it is just more ergonomic if the IDs end up as keys for Belt.Map.String (which ought to be faster than the more general Belt.Map).

Collapse
 
hoichi profile image
Sergey Samokhov

You could probably also create a functor that:

  • creates id modules with opaque ts
  • provides FooId.toString
  • also provides cmp to make it compatible with Belt.Map.MakeComparable

Unless Belt.Map.String has some string-specific code that is really faster than the generic code in Belt.Map. Does it?

Collapse
 
fhammerschmidt profile image
Florian Hammerschmidt • Edited

It says so in the docs: rescript-lang.org/docs/manual/late...

Specialized when key type is string, more efficient than the generic type, its compare behavior is fixed using the built-in comparison

Collapse
 
srikanthkyatham profile image
Srikanth Kyatham

Great article. Definitely something I will use in our project. We have faced such an issue which fixed it after runtime error. Gabriel if you could write the need for the interface files how they could be used. Honestly we have not used it much. I would really appreciate thanks. Keep up the great work.