Restricting function arguments

By leveraging Phantom and Empty types

Chris Nevin
The Alchemy Lab
3 min readOct 17, 2019

--

Photo by Stefano Pollio on Unsplash

Phantom types are values that contain a generic parameter that doesn’t appear in the implementation.

An extremely simple example of a phantom type may be:

struct ID<T> {
let value: String
}

Notice how the T is not used in the body?

This allows us to define different kinds of ID’s for different values.

typealias CustomerID = ID<Customer>
typealias CompanyID = ID<Company>

Why is this useful?

It makes your code more explicit, more expressive, and type-safe. This means you won’t be able to pass any value to a function that’s expecting a specific kind of value.

This works incredibly well for primitives — not all Strings are equal.

The example above with an ID is a rather limited one used to demonstrate the idea, we can define a more useful base type like so:

struct Phantom<T, U> {
let value: String
init(_ value: String) {
self.value = value
}
}

This allows us to provide even more context, we can pass in a data type (such as Customer or Company) and an empty type (such as ID or Name).

Empty Types

An empty type is one that has no values. We can combine these with phantom types to provide more context.

Let’s redefine the ID type leverage an empty type:

enum AnyID { }typealias ID<T> = Phantom<T, AnyID>

We can then provide default functionality to values adhering to Identifiable and PhantomType.

protocol PhantomType { }

extension PhantomType {
typealias Specific<T> = Phantom<Self, T>
}

extension Identifiable where Self: PhantomType {
typealias ID = Specific<AnyID>

static func newID() -> Specific<AnyID> {
return ID(UUID().uuidString)
}
}

Now lets look at an example implementation of the Customer object we referenced earlier using a combination of phantom and empty types to provide context for ID, FirstName, and LastName.

struct Customer: Identifiable, PhantomType {
typealias FirstName = Specific<AnyFirstName>
typealias LastName = Specific<AnyLastName>
let id: ID
let firstName: FirstName
let lastName: LastName

init?(fullName: String) {
guard let firstSpace = fullName.firstIndex(of: " "), firstSpace < fullName.endIndex else {
return nil
}
self.id = Customer.newID()
self.firstName = FirstName(fullName.prefix(upTo: firstSpace))
self.lastName = LastName(fullName.suffix(from: fullName.index(after: firstSpace)))
}
}

This object takes a full name (like “John Appleseed”) and if it contains a space will create a Customer with a generated ID, a FirstName, and a LastName.

We can even extend this example to provide a more explicit formatted FullName like so:

extension Customer {
// Compound phantom type
typealias FullName = Phantom<FirstName, LastName>
var fullName: FullName {
return firstName.value + " " + lastName.value
}
}

This makes it impossible for me to pass the wrong information to a particular function, consider we had to generate some sort of a report and wanted to print a customer’s name in the letterhead, usually we’d have to define something like this:

func generateLetterHead(for customerName: String) -> String { }

This doesn’t really provide enough context… Is it the first name of the customer? The last name? Or the full name?

Now that we’ve defined those types we can be explicit:

func generateLetterHead(for customerName: Customer.FullName) -> String { }

Misinterpretation can be rife in large code bases where lots of different developers name things differently, introducing some type-safety can help bring order to the chaos.

I hope this primer has helped you get a grasp on some new ways of using generics to provide context.

Here’s an example project demonstrating a slightly different scenario.

If you’re interested, Tigerspike is hiring. Mention my name (Chris Nevin) when you apply.

--

--