Skip to content

Instantly share code, notes, and snippets.

@busypeoples
Last active February 6, 2024 21:29
Show Gist options
  • Save busypeoples/3a28d039272ec3eb33ca2fc6b32dafc7 to your computer and use it in GitHub Desktop.
Save busypeoples/3a28d039272ec3eb33ca2fc6b32dafc7 to your computer and use it in GitHub Desktop.
Phantom types in ReasonML

Phantom types in ReasonML

Introduction

"A phantom type is a parametrised type whose parameters do not all appear on the right-hand side of its definition..." Haskell Wiki, PhantomType

The following write-up is intended as an introduction into using phantom types in ReasonML.

Taking a look at the above definition from the Haskell wiki, it states that phantom types are parametrised types where not all parameters appear on the right-hand side. Let's try to see if we can implement a similar example as in said wiki.

Updated to ReasonML 3.1 (09.03.2018)

Example

type formData('a) = string;

So formData is a phantom type as the 'a parameter only appears on the left side.

Let's create file FormData.rei to define the formData interface and add the above definition.

We want to enable a library user to create a formData. What we also want is restrict the type in certain parts of the library. For example we want to be able to differentiate between validated und unvalidated form data. So let's add two type definitions validated and unvalidated to our FormData.rei file.

type validated;
type unvalidated;

Nothing special up until here, just two lone type definitions. So for example we want to expose a function, that receives a string and returns an unvalidated formData type, which we will do by extending our API.

let formData: string => formData(unvalidated);

And maybe we want to add an upperCase function that does exactly that, take a unvalidated input and return an unvalidated input, so again let's update our API.

let upperCase: formData(unvalidated) => formData(unvalidated);

Finally let's also add a validate function that either returns nothing or the validated input.

type option('a) =
  | Some('a)
  | None;

let validate: formData(unvalidated) => option(formData(validated));

Next, we'll create a new file and name it FormData.re, where we will implement the interface.

type formData('a) = string;
  type validated;
  type unvalidated;
  let formData = a => a;
  let upperCase = a => String.uppercase(a);
  let validate = a =>
    if (String.length(a) > 3) {
      Some(a);
    } else {
      None;
    };

Let's try to use the library:

open FormData;

let a: formData(unvalidated) = formData("foobar");
let b: option(formData(validated)) = validate(a);

So we can see that this works as expected. We validate "foobar" and get the validated form input in return. But what if we wanted to call the upperCase function?

let c =
   switch (b) {
   | Some(a) => FormData.upperCase(a)
   | None => a
   };

The compiler will complain:

Error: This expression has type FormData.validated FormData.formData
       but an expression was expected of type
         FormData.unvalidated FormData.formData
       Type FormData.validated is not compatible with type
         FormData.unvalidated

Check the example

So we can't pass in a validated input into upperCase, but calling the same function with an unvalidated input will work. We can already see the benefits. This is all interesting but what if somebody wants to by pass the validation and by simply creating a validated type?

let byPassValidation: string => formData(validated) = a => a;

Interestingly the library user is free to do so.

let c = byPassValidation("ok!");

You might have noticed that we need to make an important change in our interface definition. If you recall, we defined formData as:

type formData('a) = string;

But what we actually want to do is hide the implementation, to prevent a library user to arbitrarily bypass any internals, that we're actually trying to hide away.

By simply changing the type definition to:

type formData('a);

we can guarantee that our library functions as expected. The previously defined function:

 let byPassValidation: string => formData(validated) = a => a;

will not work anymore. The compiler will display an error:

Error: This expression has type string but an expression was expected of type
         FormData.validated FormData.formData

What happens when try validate our previously validated input?

let c = validate(b);

Again we receive the appropriate error message:

Error: This expression has type
         FormData.validated FormData.formData FormData.option
       but an expression was expected of type
         FormData.unvalidated FormData.formData

Check the example

To wrap this all up, here is how our library might be implemented in the real world:

type validated;
type unvalidated;

module type FormData = {
  type t('a);
  let create: string => t(unvalidated);
  let validate: t(unvalidated) => t(validated);
  let upperCase: t('a) => t('a);
  let toString: t('a) => string;
};

module FormData: FormData = {
  type t('a) = string;
  let validate = a => a;
  let create = a => a;
  let upperCase = a => String.uppercase(a);
  let toString = a => a;
};

Check the example

Summary

With phantom types we can enforce specific types to user land without exposing how these can be constructed. This opens up a number of possible opportunities we can leverage, i.e. guaranteeing a value is > 100 throughout our application.

If you have feedback, insights or question: @twitter

@busypeoples
Copy link
Author

Yes, thanks! Fixed this now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment