Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Making Impossible States Impossible in ReasonML

Making Impossible States Impossible in ReasonML

Introduction

If you have already seen Richard Feldman's talk entitled "Making Impossible States Impossible" or have read "Designing with types: Making illegal states unrepresentable" then you can skip the explanations and just head straight to the Reason examples.

This post is intended to display how to model your Reason Application to prevent creating impossible states. The benefits of being able to design a feature in this way include avoiding having to deal with complex test scenarios regarding defined business rules and a clear documentation of what is possible just by looking at the type definition. Long story short, let's see how this all works by implementing an example.

Requirements

We have a set of requirements our functionality has to fulfill:

  • User has to have at least one way of logging into the system, either via E-Mail or via a Social Media Account.
  • A User can provide both contacts, but only one is needed to login.
  • Having both simply means that the user can login via regular User account (E-Mail) or via a Social Media Account.
  • Name (first and last name) are associated with every user (non optional).

Approaching The Problem

Ok, you might start by defining a simple Account type.

type account = {
  name: name,
  emailContact: emailContact,
  socialMediaContact: socialMediaContact
};

Also, just for clarity, here our name, emailContact and socialMediaContact types:

type name = {
  firstName: string,
  lastName: string
};

type emailContact = string;

type socialMediaContact = {
  platform: string,
  account: string
};

Everything looks reasonable so far. Our socialMediaContact type defines a platform, which could be i.e. Twitter and the associated account, the user name on said platform. name and emailContact are self explanatory.

Now back to our requirements, how can we make the contact fields optional. We know that one contact is mandatory, while providing the other is optional.

If we try to create an Account with only one of the required contact options:

let name : name = {firstName: "foo", lastName: "bar"};
let account : account = {name, emailContact: "foo(at)bar.baz"};

the compiler will complain:

Error: Some record fields are undefined: socialMediaContact

As we can see there is no possibility to enable either E-Mail or the Social Media contact being optional at the moment.

The next idea is to make the contacts optional in the contact type.

type maybe 'a =
  | Just 'a
  | Nothing;

type account = {
  name: name,
  emailContact: (maybe string),
  socialMediaContact: (maybe socialMediaContact)
};

This works. We can can create an account with only an E-Mail contact.

let account : account = {name, emailContact: (Just "foo(at)bar.baz"), socialMediaContact: Nothing};

But this also opens up the possibility to define neither an E-Mail nor a social Media account.

let account : account = {name, emailContact: Nothing, socialMediaContact: Nothing};

So we can't accomplish the needed task at hand by using a Maybe type either.

Making Impossible States Impossible

What we have noticed in the initial iteration is that we need an inclusive-or meaning something can be A or B but can also be A and B. With our previous approach we were not able to model this properly.

Having a non defined contact should be an impossible state. But how can we avoid ever being able to create an impossible state? We need to redefine account. First step is to move the possible contact options into an own type.

type contactInfo =
  | OnlyEmail emailContact
  | OnlySocialMedia socialMediaContact
  | EmailAndSocialMedia emailContact socialMediaContact;

By defining contactInfo we can now define all possible states that are needed for implementing the defined business rules. We can either have an E-Mail or a Social Media contact or have both. As you can see there is no way to create a contact without providing at least one option. All that is left to do is to adapt the account type.

type account = {
  name: name,
  contactInfo: contactInfo
};

Example

To round this all up, let's see how this can be used by completing an example.

So let's say we have a function that accepts name and email and returns an account.

let createAccountFromNameEmail name email => {
  {name, contactInfo: OnlyEmail email}
};

let name: name = {firstName: "Foo", lastName: "Bar"};
let emailContact: emailContact = "foo@bar.baz";
createAccountFromEmail name emailContact

As we can see this will return a valid account with a specified E-Mail contact.

The user might add a Social Media contact later on. Also, this can be easily achieved now that we modeled our problem this way.

let updateAccountWithSocialMedia account socialMediaContact => {
  let {name, contactInfo} = account;
  let newContactInfo =
    switch contactInfo {
    | OnlyEmail emailContact => EmailAndSocialMedia emailContact socialMediaContact
    | OnlySocialMedia _ => OnlySocialMedia socialMediaContact
    | EmailAndSocialMedia emailContact _ => EmailAndSocialMedia emailContact socialMediaContact
    };
  {name, contactInfo: newContactInfo}
};

This is all good. If you're wondering how to retrieve these values back into your view. Here's a final example function.

let getContactInfo contactInfo =>
  switch contactInfo {
  | OnlyEmail emailContact => "email: " ^ emailContact
  | OnlySocialMedia socialMediaContact => "social media: " ^ socialMediaContact.account
  | EmailAndSocialMedia emailContact socialMediaContact =>
    "email: " ^ emailContact ^ " and social media: " ^ socialMediaContact.account
  };

By simply pattern matching we can handle every case needed and return the proper representation inside a view i.e.

Check an example here.

Level Up: These 'a 'b = | This 'a | That 'b | These 'a 'b

You might have noticed that we're always dealing with the same situation here. We can have A or B, but also A and B. So we can abstract this situation by defining a these type.

type these 'a 'b =
  | This 'a
  | That 'b
  | These 'a 'b;

We're able to rewrite our previously defined contactInfo type to the following.

type contactInfo = these emailContact socialMediaContact;

Also, our previously defined getContactInfo can be rewritten to:

let getContactInfo contactInfo =>
  switch contactInfo {
  | This emailContact => "email: " ^ emailContact
  | That socialMediaContact => "social media: " ^ socialMediaContact.account
  | These emailContact socialMediaContact =>
    "email: " ^ emailContact ^ " and social media: " ^ socialMediaContact.account
  };

Check a full example here.

Summary

This write up is intended as a short introductory into how to make impossible states impossible in ReasonML. Will update to the latest ReasonML release in the coming days. If you have any questions or feedback: Twitter.

Special thanks to @schtoeffel for pointing out the these type.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.