It's been a while since I joined the Elm Slack. During this time, I have seen different folks ask how, and when, to use opaque types. I have also seen many codebases and examples where opaque types were non-existent.
I think opaque types are extremely important when it comes to API design in Elm and I suspect they are not used nearly enough. In this article we will learn how opaque types can glue an API together while making our code self-documenting and bug-free.
A common scenario
We will work with an example of what I think is a fairly common scenario: a form that validates and submits some data to create a new resource remotely. This kind of interaction is the core of many interactive web applications.
For simplicity, we will assume the resource we want to create is a very simple question with a title and a body.
First, we will start with code that does not use any opaque types. Then, we will be gradually adding them and analyzing how code has improved along the way!
Let's begin!
An example with primitives
We want to create a question. Let's say we write a module Question
with this API:
module Question exposing (create)
create : String -> String -> Task Http.Error String
This create
function takes a question title
and a body
and returns a Task
which tries to create the appropriate question. This Task
can either fail and return an Http.Error
, or succeed and return a String
representing the slug
of the brand new question.
Then, we build a form so our users can create questions. We will focus on the code that submits the form, leaving out some irrelevant details like how the fields are updated/rendered:
type alias Model =
{ title : String
, body : String
-- , ...
}
type Msg
= Submit
| QuestionCreated (Result Http.Error String)
-- | ...
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
Submit ->
( model
, Question.create model.title model.body
|> Task.attempt QuestionCreated
)
-- ...
view : Model -> Html Msg
view model =
form
[ onSubmit Submit ]
[ titleField model
, bodyField model
, submitButton model
]
Do you notice any issues here? Let's see. What happens when we Submit
our form? We create the question using Question.create
. Sounds good! But wait... Where is validation?! What happens if model.title
is empty? What if model.body
is extremely long? Hmm... Okay, no problem. We just need to add an if-then-else, right?
case msg of
Submit ->
if Question.titleIsValid model.title && Question.bodyIsValid model.body then
( model
, Question.create model.title model.body
|> Task.attempt QuestionCreated
)
else
( model, Cmd.none )
-- ...
Cool! We are done here. The new titleIsValid
and bodyIsValid
functions deal with validation and there is no way to create an invalid question now.
However, doesn't this feel a bit off? Whenever we want to use create
we have to remember to validate its input by using titleIsValid
and bodyIsValid
. What if some requirements change and create
needs another argument? We will have to remember to update the if-then-else. What if some new developer reads the type signature of create
and decides to use it somewhere else without any kind of validation? Not desirable.
In the end, our API is error-prone. It can be used incorrectly. Can we do better? Wouldn't it be nice if we could somehow tie the concept of validating and creating a question together?
Opaque types to the rescue!
Before we continue, let's review what an opaque type is:
An opaque type is a custom type without exposed constructors.
Okay, but if the constructors are not exposed... How do we use an opaque type? Well, the constructors are accessible internally, in the module where the custom type has been defined. Therefore, we can expose functions to control how the values of the opaque type are created.
Let's see how this works! Going back to our Question
module:
module Question exposing (create)
create : String -> String -> Task Http.Error String
The issue with create
is that the types of its arguments accept invalid domain values. For instance, ""
is a perfectly valid String
but it is not a valid question title.
Can we create a new custom type where its possible values are always valid question titles? Let's see:
module Question exposing (Title, create, titleFromString)
type Title
= Title String
titleFromString : String -> Result String Title
titleFromString title =
if String.length title < 5 then
Err "the title must not be less than 5 characters long"
else if String.length title > 100 then
Err "the title must not be more than 100 characters long"
else
Ok (Title title)
-- ...
That's it! Here we define a new custom type Title
without exposing its constructor. Instead, we implement a titleFromString
function which takes a String
and returns either a String
describing an error when validation fails, or a Title
when validation succeeds.
The only way to build a Title
is to use titleFromString
. As a consequence, if we have a Title
anywhere in our codebase, we can be confident that it is a valid question title. The Title
type guarantees validity.
Whenever we are validating data, we are just checking that the data has some guarantees. Performing validation and then using the same type afterwards is a missed opportunity! We should try to capture these guarantees using opaque types. As a result, our APIs will become safer and easier to understand.
Similarly, we can define a Body
type. Then, we can update the create
function:
module Question exposing (Body, Title, create, bodyFromString, titleFromString)
-- type Title ...
-- type Body ...
create : Title -> Body -> Task Http.Error String
Now, the create
function forces us to provide a valid title and a valid body. We cannot longer submit the form as we did before. We must use titleFromString
and bodyFromString
:
case msg of
Submit ->
case ( Question.titleFromString model.title, Question.bodyFromString model.body ) of
( Ok title, Ok body ) ->
-- Validation succeded
( model
, Question.create title body
|> Task.attempt QuestionCreated
)
_ ->
-- Validation failed
( model, Cmd.none )
-- ...
Our API does not provide any other way to do this. We cannot skip validation anymore! We do not even need to remember validation. The API forces us to deal with errors along the way. Our API is safer.
Further improvements
We are not done yet! There are still a couple of things we can improve.
The first improvement has to do with duplicated validation. We are currently performing validation in our update
function when the form is submitted, but we probably want to validate the form fields in our view
code too, maybe show error messages in real time. Therefore, update
and view
are both using titleFromString
and bodyFromString
: update
only cares about the successful result, while view
only cares about the errors.
Now that we have Title
and Body
types, we can make our Submit
message impossible to be fired if the form values are invalid, validating only once in view
and propagating the validation guarantees over to update
. We just need to change the Submit
message:
type Msg
= Submit Question.Title Question.Body
-- |...
case msg of
Submit title body ->
( model
, Question.create title body
|> Task.attempt QuestionCreated
)
-- ...
Clean! We got rid of the awkward no-operation in the else clause when validation failed.
This particular approach, where form validation happens in view
while capturing guarantees using opaque types, is one of the main ideas behind a package that I wrote: composable-form
. composable-form
treats forms as composable units, so they can be built, combined, and reused freely. I will write about it soon!
The second improvement is simple. We can create a Slug
opaque type. Then, the create
function will look even better:
create : Title -> Body -> Task Http.Error Slug
Now, if we ever need to allow our users to edit questions, we could just write an edit
function:
edit : Slug -> Title -> Body -> Task Http.Error Slug
Neat! Our API will explicitly say a question can only be edited if it has been previously created.
In summary
Opaque types not only make your code safer, but they can also be used to connect different concepts together, making your codebase much easier to understand.
Here is one exercise I like to perform when I code: I try to imagine the thought process that a new developer, with a specific goal in mind, will have when reading the module documentation. Let's do that with our Question
module, assuming Bob wants to find out how to create
a new question:
- Bob finds a
create
function. - Bob sees he can
create
a question if he provides aTitle
and aBody
. - Bob wonders if there is some way to build these using a
String
. - Bob finds
titleFromString
andbodyFromString
, which seem to do the job. - Bob notices those functions return a
Result
. - Bob understands he will need to handle the case where the provided
String
is not valid.
In this case, the developer can get a clear understanding of the Question
API just by looking at the type signatures. No comments needed!
This is it for now! I will write more about Elm soon.
I would like to hear about your thoughts on opaque types and form validation, so do not hesitate to comment! You can also find me (@hecrj) in the Elm Slack, I am always happy to talk.
Top comments (10)
Great explanation of opaque types, thank you!
What do you think of the following additional improvement? The model currently specifies both
title
andbody
asString
types. So in theSubmit
logic it's possible to accidentally pass the wrong string, such as Question.titleFromString model.body but then ifbody
still happens to pass the validation fortitle
your view will be wrong but give you no errors. To prevent this, create atype alias TitleString = String
and then use that in the model instead of plainString
. Then Bob will also know that TitleString and BodyString can never be interchanged accidentally. Does that seem like overkill (or have a flaw I'm not seeing)?That's not really a question about opaque types, it's on the general theme of "catch everything at compile time" which is what's great about Elm!
Good question!
The issue is that a
type alias
is just a new name for a type (an alias). Therefore,TitleString
is a just new way to sayString
. This means thatQuestion.titleFromString model.body
would still compile, even when usingTitleString
in yourModel
andQuestion
API. The compiler would seeString
everywhere.We can use an opaque type! Imagine we define a
Question.TitleField
module with this API:This module ties a a
TitleField
value with its form fieldview
and its validation. Finally, we useTitleField
in ourModel
, usingblank
to initialize it,title
for validation, andview
to render it!composable-form can help you write this:
I'm glad this led back to opaque types. 8-D Using something like
TitleField
type is probably too much for the simple example, but a great solution for a site that has to be really robust!hecrj/composable-form
looks great, I'm going to check that out too.As for type aliases... I don't know enough about compiler design to have an informed opinion, but what I described is how I would "want" type aliases to work. I totally understand that after being compiled they're all just
String
s (in this example), but if the coder specifies a different name I think I want a step somewhere that will catch that if the wrong name is used. But yourTitleField
solution is better anyway, it provides a lot more than just a new name for a type. 8-)Great post. I was wondering about the use of Http.Error. Why are you using that when creating a Question, it has nothing to do with Http?
Thank you!
As I say here:
We assume that the
Question
needs to be created remotely in some backend. Although I don't mentionHttp
specifically, the most common strategy is to do this by making a request to an HTTP API.In any case, the error type could be something entirely different. It is not relevant to understand the concepts presented in the post.
Ok, get it. Thanks.
How about to hide 'validation' behind the 'create' method?
What do you think about it?
Then the
create
function does multiple things. Not desirable. For instance, how would you show validation errors without submitting the form?For instance we have function which get 2 numbers and return sum of them. The first number is odd and the second is even.
Should we create new types for these values (like you did for title and body) because we validate them?
Do we break Single Responsobiliy Principal?
Does it make sence to create separate type if we don't use them in other places?
Opaque types allow to capture guarantees and propagate them. If you only need these guarantees on a specific part of your code and there is no need to propagate them, then it is probably not worth it to create opaque types for them. In other words, not every "validation check" should result in an opaque type.
However, most of the time this is an API design choice. For instance, I would understand this API better than the example you provided:
The advantages here are:
sum
error-free! We can call it multiple times without having to deal with errors:sum (sum odd even) even
sum
value is guaranteed to be anOdd
number.