Opaque Types - better type safety and easier to understand code
What is an Opaque Type?
An opaque type is a type whose concrete data structure is not defined in an interface (i.e. it is not visible to the outside world). It is kind of like encapsulation but for types. For example, say you have an User
type with a id
field, which is a string.
You could define your User
type with id
defined as a string
directly (without using an opaque type), but this means any string could be used as a user id, no matter how you got that string or where it might come from.
Instead, you could create an opaque type called UserId
, which uses a string
as its underlying data structure, and then say that the User
's id
is of type UserId
.
Why do I care?
From an application code perspective, not using opaque types means it is impossible for the compiler to catch certain classes of bugs (like passing the user's email
to a function expecting its id
). It also means that we as developers need to know where all of our primitive data needs to come from. For example, we might have a legacy function with a signature defined as getUser(key: string)
that expects the id
but names it key
locally.
Let's check out an example!
Example - User Client
Using transparent types
We have a file in our TS frontend code called ApiClient
that is responsible for making requests to our REST API.
Let's take a look at a couple of functions in the ApiClient
module - getUser
and getPost
:
// based on real legacy code in an app I work on,
// shortened for brevity sake and with the domains changed
const ApiClient = {
getUser: async (id: string) => {
return sendToApi(`/users/${id}`)
},
getPost: (key: string, id: string) => {
return sendToApi(`/users/${key}/posts/${id}`)
}
}
In the first method, we are accepting something called id
, which is a string. In the second, we are accepting two string args: key
, and id
. If we look at the usage of these args, we can sort-of guess that the id
of the first function is the same thing as the key
of the second function. We can't know that for sure though unless we open up the REST API code and hunt down that endpoint to see what it does with that param.
We also then have to deal with this id
param in the second function. Again, we can guess that it has something to do with the post
being fetched, but we don't know for sure what string
we should use (is there a post.id
? post.uuid
? post.key
? post.someOtherThingJustHereToTrickYou
?). Again, we have to open up the REST API code to see what is going on.
Let's see how this could be much nicer with opaque types!
Using opaque types
// This Opaque helper can be used to create opaque types.
// Don't worry about its implementation for now.
// Just know that we can use `unique symbol` to ensure
// that no two opaque types can be assigned to each other.
declare class OpaqueTag<S extends symbol> {
private tag: S
}
export type Opaque<T, S extends symbol> = T & OpaqueTag<S>
declare const UserIdS: unique symbol
type UserId = Opaque<string, typeof UserIdS>
declare const PostIdS: unique symbol
type PostId = Opaque<string, typeof PostIdS>
interface User {
id: UserId
// ... the rest of it
}
interface Post {
id: PostId
// ... the rest of it
}
// in ApiClient module
const ApiClient = {
getUser: async (id: UserId) => {
return sendToApi(`/users/${id}`)
},
getPost: (key: UserId, id: PostId) => {
return sendToApi(`/users/${key}/posts/${id}`)
}
}
Now, we have a much better idea what is happening from looking at this code. We can clearly see that the id
being used in getUser
is the User
type's id
field. We also can see in getPost
what models the id
and key
arguments come from, which again saves us valuable time double checking our assumptions against the REST API project code.
Note: It is important to use the
Opaque
generic type to create opaque types. You may be tempted to try something like this:type PostId = string
Unfortunately, this just creates a type alias, which means that the compiler treats all
PostId
types as transparentstring
types.
Compiler goodness
The magic really starts to happen when we want to use these functions. In our calling code, it is now impossible for us to pass the wrong string. Let's look at a usage example with the original definition of getPost
:
const ApiClient = {
getPost: (key: string, id: string) => {
return sendToApi(`/users/${key}/posts/${id}`)
}
}
It is so easy to call this function incorrectly (especially when dealing with copy-paste or merge conflict issues). For example, this code will compile happily, not alerting you of your mistake:
const submit = async () => {
// Args are in the wrong order! Compiler don't care!
const res = await ApiClient.getPost(
post.id,
user.id,
)
if (res.type === 'success') {
// do stuff for succes
} else {
// do stuff for failure
}
}
With opaque types though, this will blow up:
const submit = async () => {
// Args are in the wrong order! Compiler don't care!
const res = await ApiClient.getPost(
post.id,
// => TypeError: Type 'PostId' is not assignable to type 'UserId'
user.id,
)
if (res.type === 'success') {
// do stuff for succes
} else {
// do stuff for failure
}
}
Now, we have compiler checks to make sure our data always comes from the right place! That is pretty sick.
De-serialization, type guards, and typecasting
So you may be wondering, "how do I get one of these types?", which is a perfectly valid question. There are a few answers for this that I can think of.
1 - De-serialization
This is where you get the data from a network request and type the response manually. For example, when you get a User
response from the API, the id
field is a UserId
. This is great because it means you can track that UserId
as it flows through the application code, comfortable in the knowledge that that underlying string did indeed come from the REST API returning an User
.
type Result<T> = Success<T> | Err
const ApiClient = {
login: (email: string, password: string): Result<User> => {
return sendToApi('/login')
}
}
2 - Type guards
Sometimes you need to get an opaque type via validation. This is where type guards come in handy. Let's say you have parts of the application code that expect data to already be validated when it is used. A great example of this is a signup function that sends a user email to the API to create an account. We can use an opaque Email
type with a typeguard to ensure we always validate the email before using.
// a very pared-down, contrived example
declare const EmailS: unique symbol
type Email = Opaque<string, typeof EmailS>
const isEmail = (x: string): x is Email => /emailregex/.test(x)
const signup = (email: Email) => {
return sendToApi('/users/signup', { email })
}
Now, the only way to get an Email
type is to check that a string
is an email using the isEmail
typeguard function. This is how we would use it to ensure we are only sending valid emails to the API:
const unvalidatedEmail = 'some user input string'
signup(unvalidatedEmail)
// => TypeError: Type 'string' is not assignable to type 'Email'
if (isEmail(unvalidatedEmail) {
signup(unvalidatedEmail)
// => works great :)
}
You can also combine this behavior with something called a smart constructor to get compile-time validation on runtime data. Checkout this post to see what that is all about
3 - Typecasting
This is the last resort and should really only be done when you have no other choice. For example, maybe the type definitions for a library suck so you need to work around them. In this case, you get an opaque type by typecasting:
// some react component
interface Props {
Posts: Post[]
}
const MyComponent = ({ posts }: Props) => {
const handleSelect = (key: PostId) => {
PostClient.getPost(key)
// do other stuff
}
return (
<SomeLibraryComponent
collection={posts}
onSelect={(key) => handleSelect(key as unknown as PostId)}
/>
)
}
That's it!
Thanks for reading!
Want more of that sweet, sweet content? Here ya go.