United Kingdom: +44 (0)208 088 8978

Working with Phantom Types

Isaac shows us a lesser-known feature of F# that can aid code reuse.

We're hiring Software Developers

Click here to find out more

In F#, we often use single-case discriminated unions as a way to store identifiers of an value e.g.

type CustomerId = CustomerId of Guid // single case union for CustomerId wrapping a Guid

type Customer = {
    Id : CustomerId // Not just a Guid, but a CustomerId
    Name : string
}

The use of a GUID for the identifier here is just as an example. There are many reasons why you might not want to use GUIDs for an ID, particularly within the context of performance and efficiency in a database.

Single case unions are extremely useful, but you'll often want to add more logic to make it easier to work with such a type:

// A struct may be more efficient for a single-case DU.
[<Struct>]
type CustomerId =
    // A private constructor to prevent creating a CustomerId with "bad" data
    private
    | CustomerId of Guid

    /// This static member function is the only way to create a CustomerId
    static member TryCreate(guid: string) : Result<CustomerId, string> =
        match Guid.TryParse guid with
        | true, guid when guid = Guid.Empty -> Error "Empty guid"
        | true, guid -> Ok(CustomerId guid)
        | false, _ -> Error "Invalid guid"

    /// This member quickly unwraps the actual GUID value if needed.
    member this.Value =
        match this with
        | DbId v -> v

The challenge comes if and when you want to implement the same pattern for multiple types e.g. OrderId, UserId, DepartmentId etc. In such a case, you have several options:

  1. Copying and pasting. Duplicating the code between each Id may be quick to do but as you evolve these "reusable" functions over time, maintenance costs will grow.
  2. Delegating. Creating reusable functions may be possible, but you will still need to create the same members on each type and delegate to the "real" implementation behind the scenes.
  3. Use Statically Resolved Type Parameters. SRTPs can solve many problems, potentially including some of the above, but they can be difficult to work with and are a more advanced feature.

Phantom Types to the rescue

An alternative to the above is to use a Phantom Type. A Phantom Type is (at least for the purposes of this post!) a generic type argument that is never used by the containing type. Let's take our original, simple CustomerId implementation and make it work with a Phantom Type so that it can operate on any type, not just CustomerId:

open System

type CustomerId = interface end // A "marker" interface
type DbId<'T> = DbId of Guid // A generic "database ID" that can be used for any entity.
let customerId : DbId<CustomerId> = DbId Guid.Empty

Notice that we do not need to "wrap" the DbId value in anything else - it is simply typed as a DbId<CustomerId>. This isn't the same as a type alias - it's a full type that will exist at runtime (it's not erased!):

type OrderId = interface end // Another "marker" interface
let orderId : DbId<OrderId> = DbId Guid.Empty

orderId = customerId // error FS0001: Type mismatch. Expecting a 'DbId<CustomerId>' but given a 'DbId<OrderId>'

You can use this same technique of Phantom Types on Records as well as Discriminated Unions.

Let's now apply the same technique to our larger example:

[<Struct>]
// The use of 'T here is the Phantom Type argument.
type DbId<'T> =
    private
    | DbId of Guid

    // 'a here is also a Phantom Type argument
    static member TryCreate<'a>(guid: string) : Result<DbId<'a>, string> =
        match Guid.TryParse guid with
        | true, guid when guid = Guid.Empty -> Error "Empty guid"
        | true, guid -> Ok(DbId guid)
        | false, _ -> Error "Invalid guid"

    member this.Value =
        match this with
        | DbId v -> v

We can now compose this type together with the CustomerId marker interface to create a DbId for a CustomerId:

let customerId = DbId.TryCreate<CustomerId> "01000000-0000-0000-0000-000000000000"
// customerId is a Result<DbId<CustomerId>, string>

If we want an OrderId (assuming that it has the same validation rules as a CustomerId), we need only create an OrderId marker interface and use it with the DbId.TryCreate method as shown above.

Since DbId is generic, we can also use it on both closed and open variants:

// Open generic type - works on any DbId
let isUkBased (dbId: DbId<_>) =
    match dbId.Value.ToString()[0] with
    | '0' -> true
    | _ -> false

// Closed generic type - only works with CustomerIds
let isHighValueCustomer (cId: DbId<CustomerId>) =
    match cId.Value.ToString()[1] with
    | '1' -> true
    | _ -> false

isHighValueCustomer userId // error FS0001: Type mismatch. Expecting a 'DbId<CustomerId>' but given a 'DbId<UserId>'

Summary

Phantom types are a useful way of capturing reusable data and logic across multiple types. They are fairly cheap to implement, not especially difficult to reason about and have good runtime performance characteristics.