Phantom types with Flow

Adapted from “Phantom type” Haskell Wiki

A phantom type is a parametrised type whose parameters do not all appear on the right-hand side of its definition, e.g

class Data<M> {
value: string;
constructor(value: string) {
this.value = value
}
}

Here Data is a phantom type, because the M parameter doesn’t appear in its implementation.

Why? An example: validating user input

Say you have a use(input: string) => void function (the return type doesn’t really matter for our example), perhaps it saves the input to a database, or calls an internal API. Now you want to enforce that, before being called, the input has been validated. And maybe you also don’t want to waste CPU cycles (let’s assume the process of validating is expensive) so you want to ensure that the validation happens only once, what would you do?

Let’s start with a few definitions

// @flow
class Data<M> {
value: string;
constructor(value: string) {
this.value = value
}
}

The Data class looks strange since at first it seems the type parameter is unused and could be anything, without affecting the value inside. Indeed, one can write

function changeType<A, B>(data: Data<A>): Data<B> {
return new Data(data.value)
}

to change it from any type to any other. However, if the constructor is not exported then users of the library that defined Data can’t define functions like the above, so the type parameter can only be set or changed by library functions. So we might do:

class Validated {}
class Unvalidated {}
// since we don’t export the constructor itself,
// users with a string can only create Unvalidated values
export function make(input: string): Data<Unvalidated> {
return new Data(input)
}
// returns null if the data doesn’t validate
export function validate(data: Data<Unvalidated>): ?Data<Validated> {
return isAlpha(data.value) ? new Data(data.value) : null
}
// can only be fed the result of a call to validate!
export function use(data: Data<Validated>): void {
console.log(‘using ‘ + data.value)
}

Now let’s try to use the library incorrectly

import { make, validate, use } from './Data'
const data = make('hello')
use(data) // called without validating the input

If you run Flow you get the following error

use(data)
^^^^^^^^^ function call
Data<Unvalidated> {
     ^^^^^^^^^^^ Unvalidated. This type is incompatible with
use(data: Data<Validated>): void {
               ^^^^^^^^^ Validated

If you call validate instead

const data = make('hello')
const validatedData = validate(data)
if (validatedData) {
use(validatedData)
}

everything is fine and you’ll be rewarded by a sweet “No errors!message.

The beauty of this is that we can define functions that work on all kinds of Data, but still can’t turn unvalidated data into validated data

// changing the case is compatible with the defined validation
export function toUpperCase<M>(data: Data<M>): Data<M> {
return new Data(data.value.toUpperCase())
}

One last thing, what happens if you try to validate the input twice?

const data = make(hello)
const validatedData = validate(data)
validate(validatedData)

Flow complains!

validate(validatedData)
^^^^^^^^^^^^^^^^^^^^^^^ function call
Data<Unvalidated>): ?Data<Validated> {
     ^^^^^^^^^^^ Unvalidated. This type is incompatible with
?Data<Validated> {
      ^^^^^^^^^ Validated

This technique is perfect for validating user input to a web application. We can ensure with zero overhead that the data is validated once and only once everywhere that it needs to be, or else we get a compile-time error.