Skip to content

Instantly share code, notes, and snippets.

@phlippieb
Created December 2, 2021 11:52
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save phlippieb/58bc9b49e6a3c1343946de8650c5c3f0 to your computer and use it in GitHub Desktop.
Save phlippieb/58bc9b49e6a3c1343946de8650c5c3f0 to your computer and use it in GitHub Desktop.
A pattern for preventing intermixed types that represent non-interchangeable concepts

The problem

Consider two models defined like this:

struct User {
  let id: Int
  let name: String
  let email: String
}

struct Movie {
  let id: Int
  let title: String
}

These are clearly two seperate concepts. Moreover, when we refer to the id of a user, that should preferably also be a seperate, non-interchangeable concept from the id of a movie. However, nothing prevents us from using those ids interchangeably:

func getUser(with id: Int) -> User? {
  ...
}

let movie = Movie(id: 1, title: "Metropolis")
getUser(with: movie.id) // Logically wrong, but technically allowed

Similarly, a user's name and email are both represented as String, even though they should not be interchangeable.

func sendVerification(to email: String) {
  ...
}

sendVerification(to: user.name) // Wrong, but allowed

Simple fix with domain-specific types

We can address this type of scenario by creating new types for each concept that should be "protected" from being interchanged with others. A simple implementation would be something like this:

struct User {
  let id: UserId
  let name: UserName
  let email: Email
}

struct UserId {
  let id: Int
}

struct UserName {
  let name: String
}

struct Email {
  let email: String
}

By doing this, we can leverage the compiler to prevent concept-mixing.

func sendVerification(to email: Email) {
  ...
}

sendVerification(to: user.email) // Works
sendVerification(to: user.name) // Does not compile

However, this simple fix might not address all our requirements. We got rid of the old types (Int, String), which came with a lot of conveniences, such as being hashable, equatable, encodable, decodable, etc. If we have other code that relied on those conveniences, we will need to re-implement them for our new types. This might dissuade us or our teammates from using these special types in the long run.

Tagged

Our simple solution worked for a simple reason: each new type was recognized by the compiler as a new type. Another way to achieve this would be to use generics. So let's define a reusable wrapper for any type that we want to protect from inter-mixing, that also lets us seperate them by domain:

struct Tagged<Tag, WrappedValue> {
  var wrappedValue: WrappedValue
}

Very simple! Even though we don't use the Tag type in the struct's definition, we can now use that type parameter to create distinct types.

Emails are presumably a fairly general concept, so we'll give them a type-alias in the global scope. We can change our email type to this:

typealias Email = Tagged<EmailTag, String>
enum EmailTag {} // A non-type that differentiates emails from other tagged strings

By contrast, user IDs and user names are very specific to users, and movie IDs are specific to movies. So we can use the concept-defining types as the tags:

struct User {
  typealias Id = Tagged<User, Int>
  typealias Name = Tagged<User, String>

  let id: Id
  let name: Name
  let email: Email
}  

struct Movie {
  typealias Id = Tagged<Movie, Int>

let id: Id
  let title: String
}

As with our simple solution, we're correctly prohibited from mixing unrelated types. We can't pass a User.Id to a function that expects a Movie.Id, and we can't pass a User.Name to a function that expects an Email.

So what about those conveniences that we lost with the simple solution? We still have to add them. However, since we're reusing the same generic type everywhere, we only need to add them once, to Tagged.

extension Tagged: Equatable where WrappedValue: Equatable {
  ...
}

By factoring out the concept of creating non-mixable, domain-specific types into a reusable wrapper, we can add all the conveniences we need to that wrapper and enjoy the benefits throughout our codebase.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment