"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.
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
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
At the beginning,
unvalidated formData
and a subsequent one is flipped.formData unvalidated