This is part of a short series of Flow/TypeScript posts I’m calling “Effective Types”. Posts so far:

Phantom Types are a way to add extra information to types, eg. to differentiate them, in such a way so that the extra information goes away when type-checking is complete. They’re useful when the underlying value is the same, but you want to differentiate how they’re used.

This post relies pretty heavily on understanding type parameters and “type wrappers” / new-types, and I recommend you understand both of those posts before digging in to this one.

Ghost Stories

Phantom Types

Let’s start with a useless class with a type parameter:

class Useless<T> {
  // Absolutely nothing.
}

We have a type parameter that’s completely spare; nothing is using it. But look what happens when we create a couple of values, and fill in that parameter with a type:

import { isEqual } form 'lodash'; // We'll use this later.

// Create two values.
const uString: Useless<string> = new Useless();
const uNumber: Useless<number> = new Useless();

console.log(isEqual(uString, uNumber)); // => true; effectively the same, value-wise.

// But...
function doNothing(x: Useless<string>) { return; }

doNothing(uString); // <-- Compiles!
doNothing(uNumber); // <-- Kaboom! 💥
                    // Expected a Useless<string>, but
                    // got a Useless<number> instead.

We have two values (instances of Useless) that are completely identical when we run the code (“at run-time”), but have different types. This is the essence of Phantom Types.

Here’s one more example, this time with something that isn’t a blank class:

class Temperature<T> {
  degrees: number;
  constructor(n: number) {
    this.degrees = n;
  }
}

// Just define a couple of types; they're not actually used for real values.
// It's like we're defining our own "string" or "number" types to sub in, like
// how we used them in the previous example.
class Fahrenheit {}
class Celsius {}

// Same "Temperature" abstract type; different type parameters:
const hot: Temperature<Fahrenheit> = new Temperature(100);
const boiling: Temperature<Celsius> = new Temperature(100);

console.log(hot.degrees === boiling.degrees); // => true; the same number value.

// But...
function convertFtoC(t: Temperature<Fahrenheit>): Temperature<Celsius> {
  return new Temperature((t.degrees - 32) / 1.8);
}

convertFtoC(hot);     // <-- Compiles!
convertFtoC(boiling); // <-- Kaboom! 💥
                      // Expected a Temperature<Fahrenheit>, but
                      // got a Temperature<Celsius> instead.

Phantom Types with Type Wrappers

If you remember the the last post, you’ll quickly see that this Temperature class is basically the same as an class Temperature<T> extends TypeWrapper<number>. You could do the manual version, but the TypeWrapper version is just a quicker way to define the type, and gives you standardised methods for wrapping up and extracting the value.

Last example, using a TypeWrapper:

import { TypeWrapper } from 'flow-classy-type-wrapper';

class Temperature<T> extends TypeWrapper<number> {}
class Fahrenheit {}
class Celsius {}

function convertFtoC(f: Temperature<Fahrenheit>): Temperature<Celsius> {
  return Temperature.wrap(
    (Temperature.unwrap(n) - 32) / 1.8
  );
}

const hot: Temperature<Fahrenheit> = new Temperature(100);

convertFtoC(hot); // <-- Compiles!

Examples

Validation

(This example was partly drawn from https://wiki.haskell.org/Phantom_type)

Besides the aforementioned Temperature example (which is valid enough in itself), it’s also useful for tagging a value as having gone through some process:

class FormEmail<T> extends TypeWrapper<string> {}
class Validated {}
class Unvalidated {}

function validateEmail(x: FormEmail<Unvalidated>): (FormEmail<Validated> | null) {
  const email = FormEmail.unwrap(x);
  if (email.match(/@/)) {
    return FormEmail.wrap(email);
  }
  return null;
}

function createFormEmail(x: string): FormEmail<Unvalidated> {
  return FormEmail.wrap(x);
}

// Later:
const email = createFormEmail("alice@example.com");
const goodEmail = validateEmail(email); // <-- Compiles!
if (goodEmail) {
  // goodEmail is known to be a FormEmail<Validated> now.
  console.log("Validated!");

  const gooderEmail = validateEmail(goodEmail);
  // Kaboom! 💥  ------^
  // We gave it a FormEmail<Validated>, but it was expecting
  // a FormEmail<Unvalidated>. No double-validation allowed.
}

(The unwrapping and rewrapping in validateEmail is necessary to convince Flow that we’ve intentionally made the jump from Unvalidated to Validated. We could bypass this with an (... : any), but that’s too easy to hold incorrectly; I’ll take the safe-but-boring method.)

… Or even:

// Now FormData is completely generic:
class FormData<Value,Phantom> extends TypeWrapper<Value> {}
class Validated {}
class Unvalidated {}

function createFormData<T>(x: T): FormData<T,Unvalidated> {
  return FormData.wrap(x);
}

// With some value-specific validation functions:

function validateEmail(
  x: FormData<Email,Unvalidated>
): (FormData<Email,Validated> | null) {
  // ...
}

function validateDropdownSelection<T>(
  item: FormData<T,Unvalidated>,
  options: Array<T>
): (FormData<T,Validated> | null) {
  // ...
}

Combining this with the “Gatekeeper” functions trick, only exporting the FormData type, createFormData, unwrap and validation functions, means you have a practically airtight way of making sure new data passes through your validation layer before being used.

State Transitions

This is the more generic version of the Validation example above; previously, we had a simplistic State Machine that started at Unvalidated and (via validate...()) transitioned to Validated.

Say we have a plane that is always represented by a value (an object with its flightCode, for example). The plane can go through a number of different states (being on the ground, taxiing, etc), but only some of the transitions are valid.

As a final whimsical example rounding off this post, we can use Phantom Types to encode this:

class Plane<State> extends TypeWrapper<{flightCode: string}> {}

class OnGround {}
class Taxiing {}
class InAir {}
class OnApproach {}

function taxi(p: Plane<OnGround>): Plane<Takiing> { return Plane.wrap(Plane.unwrap(p)); }
function takeOff(p: Plane<Taxiing>): Plane<InAir> { /* ... */ }
function descend(p: Plane<InAir>): Plane<OnApproach> { /* ... */ }
function land(p: Plane<OnApproach>): Plane<OnGround> { /* ... */ }
function crash<T>(p: Plane<T>): Plane<OnGround> { /* ... ouch. */ }