Creating type-safe identifiers for your Codable models

Published by donnywals on

Note:
After publishing this article, it has been brought to my attention that the folks from @pointfreeco have a very similar solution for the problems I outline in this post. It's called tagged and implements the same features I cover in this post with several useful extensions. If you like this post and plan to use the concepts I describe, you should take a look at tagged.

It seems that on the Swift forums, there are a couple of topics that come up regularly. One of these topics is the newtype for Swift discussion. Last week, I saw a new topic come up for newtype and I realized that I used to wonder why folks wanted that features and I was opposed to it, and now I'm actually sort of in favor of it.

Newtype, in short, is a feature that allows you to not just create a typealias, but actually allows you to clone or copy a type and create a whole new type in the process. You can almost think of it as subclassing for structs, but it's not quite that. If you want to understand newtype in detail, I can recommend that you take a look at the Swift forum topic. The use cases for a feature like newtype are explained decently over there.

In this post, my goal is not to talk about newtype and convince why we need it. Instead, I wanted to write about one of the problems that are solved by newtype and a solution I came up with that you can take and use in your projects without having a newtype object. By the end of this post, you will understand exactly why you'd want to have type-safe identifiers for your models, what I mean when I say type-safe identifiers and how you can implement them on your models, even if they conform to Codable without having to make huge changes to your codebase.

Understanding why you'd want to have type-safe identifiers

When you're working with models or identifiers, it's not uncommon to have models that use Int or String as their unique identifiers.

For example, here's an example of three models that I prepared for this post:

struct Artist: Decodable {
  let id: String
  let name: String
  let recordIds: [String]
}

struct Record: Decodable {
  let id: String
  let name: String
  let artistId: String
  let songIds: [String]
}

struct Song: Decodable {
  let id: String
  let name: String
  let recordId: String
  let artistId: String
}

These models represent a relationship between artists, records and songs. These models are nothing special. They use String for their unique identifier and they refer to each other by their identifiers.

When you write an API to find the objects represented by these models, that API would probably look a bit like this:

struct MusicApi {
  func findArtistById(_ id: String) -> Artist? {
    // lookup code
  }

  func findRecordById(_ id: String) -> Record? {
    // lookup code
  }

  func findSongById(_ id: String) -> Song? {
    // lookup code
  }
}

I'm sure you're still with me at this point. If you've ever written an API that looks items up by their identifier this code should look extremely familiar. If a match is found, return the found object and otherwise return nil.

What's interesting here is that it's easy to make mistakes. For example, consider the following code:

let api = MusicApi()
let song = Song(id: "song-1", name: "A song", recordId: "record-1", artistId: "artist-1")

api.findRecordById(song.artistId) // nil

Can you see what's wrong in this code?

We're trying to find a record using song.artistId and the compiler is fine with that. After all, artistId, recordId and even id are all properties on Song and they are all instances of String. In fact, there's nothing stopping us from using a bogus string as an input for findRecordById.

Preventing mistakes like the one I just showed is the entire purpose of having type-safe identifiers. If you can get the Swift compiler to tell you that you're using an artist identifier instead of a record identifier, or that you're using a plain string directly instead of an identifier that you created explicitly, your code will be much safer and more predictable in the long run which is fantastic.

So how do we achieve this?

Implementing type-safe identifiers

Let's look at the refactored MusicApi struct before I show you how I implemented my type-safe identifiers. Once you've seen the refactored MusicApi I think you'll have a much better understanding of what it is I was trying to achieve exactly:

struct MusicApi {
  func findArtistById(_ id: Artist.Identifier) -> Artist? {
    // lookup code
  }

  func findRecordById(_ id: Record.Identifier) -> Record? {
    // lookup code
  }

  func findSongById(_ id: Song.Identifier) -> Song? {
    // lookup code
  }
}

Instead of accepting String for each of these method, I expect a specifc kind of object. Because of this, it should be impossible to write the following incorrect code:

let api = MusicApi()
let song = Song(id: "song-1", name: "A song", recordId: "record-1", artistId: "artist-1")

api.findRecordById(song.artistId) // error: cannot convert value of type 'Artist.Identifier' to expected argument type 'Record.Identifier'

The compiler simply won't allow me to do this because the type of artistId on Song is not a Record.Identifier. To show you how this Identifier type works, I want to show you an updated version of the Artist model since that's the simplest model to change. Here's what the old model looked like:

struct Artist: Decodable {
  let id: String
  let name: String
  let recordIds: [String]
}

Very straightforward, right? Now let's look at the updated Artist model and its nested Artist.Identifier:

struct Artist: Decodable {
  struct Identifier: Decodable {
    let wrappedValue: String

    init(_ wrappedValue: String) {
      self.wrappedValue = wrappedValue
    }

    init(from decoder: Decoder) throws {
      let container = try decoder.singleValueContainer()
      self.wrappedValue = try container.decode(String.self)
    }
  }

  let id: Artist.Identifier
  let name: String
  let recordIds: [Record.Identifier]
}

The Identifier struct is nested under Artist which means its full type is Artist.Identifier. This type wraps a string, and it's Decodable. I have defined two initializers on Identifier. One that takes a wrapped string directly, allowing you to create instances of Identifier with any string you choose, and I have defined a custom init(from:) to implement Decodable manually for this object. Note that I use a single value container to extract the string I want to wrap in this Identifier. I won't go into all the fine details of custom JSON decoding right now but consider the following JSON data:

{
  "id": "b0a16f6e-1bf9-4007-807d-d1a59b399a64",
  "name": "Johnny Cash",
  "recordIds": ["9431bcb0-f83b-4eeb-8932-dd105584ca29"]
}

The data represents an Artist object and it has an id property which is a String. This means that when a JSONDecoder tries to decode the Artist object's id property on the original model, it could simply decode the id into a Swift String. Because I changed the type of id from String to Identifier, we need to do a little bit of work to convert "b0a16f6e-1bf9-4007-807d-d1a59b399a64" (which is a String) to Identifier. The Decoder that is passed to the Identifier's custom init(from:) initializer only holds a single value which means we can extract this single value, decode it as a string and assign it to self.wrappedValue.

By implementing Identifier like this, we don't need to do any additional work, the JSON can remain as it was before, we don't need custom decoding logic on Artist.Identifier and only need to extract a single value in the Artist.Identifier custom decoder. Notice that I changed the array of recordIds from [String] to [Record.Identifier]. Let's look at the refactored implementations for Record and Song:

struct Record: Decodable {
  struct Identifier: Decodable {
    let wrappedValue: String

    init(_ wrappedValue: String) {
      self.wrappedValue = wrappedValue
    }

    init(from decoder: Decoder) throws {
      let container = try decoder.singleValueContainer()
      self.wrappedValue = try container.decode(String.self)
    }
  }

  let id: Record.Identifier
  let name: String
  let artistId: Artist.Identifier
  let songIds: [Song.Identifier]
}

struct Song: Decodable {
  struct Identifier: Decodable {
    let wrappedValue: String

    init(_ wrappedValue: String) {
      self.wrappedValue = wrappedValue
    }

    init(from decoder: Decoder) throws {
      let container = try decoder.singleValueContainer()
      self.wrappedValue = try container.decode(String.self)
    }
  }

  let id: Song.Identifier
  let name: String
  let recordId: Record.Identifier
  let artistId: Artist.Identifier
}

Both objects implement the same Identifier logic that was added to Artist which means that we can update all String identifiers to their respective Identifier objects. The implementation for each Identifier is the same every time which makes this code pretty repetitive. Let's refactor the models one last time to remove the duplicated Identifier logic:

struct Identifier<T, KeyType: Decodable>: Decodable {
  let wrappedValue: KeyType

  init(_ wrappedValue: KeyType) {
    self.wrappedValue = wrappedValue
  }

  init(from decoder: Decoder) throws {
    let container = try decoder.singleValueContainer()
    self.wrappedValue = try container.decode(KeyType.self)
  }
}

struct Artist: Decodable {
  typealias IdentifierType = Identifier<Artist, String>

  let id: IdentifierType
  let name: String
  let recordIds: [Record.IdentifierType]
}

struct Record: Decodable {
  typealias IdentifierType = Identifier<Record, String>

  let id: IdentifierType
  let name: String
  let artistId: Artist.IdentifierType
  let songIds: [Song.IdentifierType]
}

struct Song: Decodable {
  typealias IdentifierType = Identifier<Song, String>

  let id: IdentifierType
  let name: String
  let recordId: Record.IdentifierType
  let artistId: Artist.IdentifierType
}

Instead of giving each model its own Identifier I have created an Identifier struct that is generic over T which represents the type it belongs to and KeyType which is the type of value that the Identifier wraps. In my example I'm only wrapping String identifiers but making the Identifier struct generic allows me to also use Int, UUID, or other types as identifiers.

Each model now defines a typealias to make working with its identifier a bit easier.

With this update in place, we should also update the MusicApi object one last time:

struct MusicApi {
  func findArtistById(_ id: Artist.IdentifierType) -> Artist? {
    // lookup logic
  }

  func findRecordById(_ id: Record.IdentifierType) -> Record? {
    // lookup logic
  }

  func findSongById(_ id: Song.IdentifierType) -> Song? {
    // lookup logic
  }
}

With this code in place, it's now impossible to accidentally use a bad identifier to search for an object. This means that when we want to look up an Artist, we need to obtain or create an instance of Artist.IdentifierType which is a typealias for Identifier<Artist, String>. Pretty cool, right!

And even though we added a whole new object to the models, it still decodes the same JSON that it did when we started out with String identifiers.

In summary

In this week's post, I offered you a glimpse into what happens when I notice something on the Swift forums that I want to learn more about. In this case, it was a discussion about a potential newtype declaration in Swift. In this post I explored the problem that could be solved by a newtype which is the ability to create a copy of a certain type to make it clear that that copy should not be treated the same as the original type.

I demonstrated this by showing you how a String identifier can be error-prone if somebody accidentally uses the wrong kind of identifier to search for an object in a database. The Swift compiler can't tell you that you're using the wrong identifier because all String instances are equal to the compiler. Even when you hide them behind a typealias.

To work around this problem and help the Swift compiler I showed you how you can create a new struct that wraps an identifier and adds type safety to the identifiers for my models. I made this new Identifier object generic so it's very flexible and because it implements custom decoding logic it can decode a JSON string on its own which means that we can still decode the same JSON that the original String based model could with the added benefit of being type-safe.


Learn Combine with my new book

Learn everything you need to know about Combine and how you can use it in your projects with my new book Practical Combine. You'll get eleven chapters, a Playground and a handful of sample projects to help you get up and running with Combine as soon as possible.

The book is available as a digital download for just $19.99!

Get Practical Combine

Receive weekly updates about my posts

Categories: Swift