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:
- 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. - 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.
- 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.