Abstract types are types with no content. They look like this in 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:
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:
@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:
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
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>,
}
- We're adding two abstract types for the two types of ID:s we have,
userId
andorganizationId
. - We're adding a module called
Id
, with a single typet<'innerId>
. This looks a bit weird, and we could've skipped this and just useduserId
andorganizationId
directly. But, this is important for convenience. You'll see why a bit later. - We change the
id
fields foruser
andorganization
to beId.t<userId>
andId.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
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:
- We're catching the error we made previously - yay!
-
setUserEmail
is still expecting astring
for the user ID, but it should now expect our newId.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"
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
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) {
...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"
This produces:
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:
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:
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:
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:
- 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. - The signature has
t
as an abstract type, but the implementation aliasest
tostring
. Uh-oh, aren't we leaking the underlyingstring
now? We're not, because in the signaturet
is still abstract, and that's what the outside sees. - We've added a
toString
function that takes anId.t
and returns astring
, which is exactly what we needed. Two things to notice about this. First, the signature says it takesId.t<_>
and produces astring
._
in this case means "I don't care about the inner type" -toString
can be used on anyId.t
, it doesn't matter what inner type we've said it is, it's still astring
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 astring
.
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 runtoString
on it to get astring
, because the signature says so. - The module itself knows that an
Id.t
is really astring
, and therefore that it's fine to just return itself when converting anId.t
to astring
.
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}".`,
)
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)
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 generalBelt.Map
).You could probably also create a functor that:
t
sFooId.toString
cmp
to make it compatible withBelt.Map.MakeComparable
Unless
Belt.Map.String
has some string-specific code that is really faster than the generic code inBelt.Map
. Does it?It says so in the docs: rescript-lang.org/docs/manual/late...
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.