Better Swift Codable Models Through Composition

Photo by Iker Urteaga via Unsplash

Imagine we want to build an app to track books. The API we are building against provides JSON for authors and books, which looks like this:

Swift’s Codable features enable us to quickly create matching models:

Thanks to Codable this is all we have to do to get JSON mapping to type-safe models for free!

There are, however, a couple of subtle issues that could cause problems as we progress. The identifiers are defined as Strings. This isn’t wrong, but it could lead to a scenario like this:

This would compile, and although the call-site seems to read correctly, it would never return anything because the function expects an author’s identifier, not their name. Let’s look at a type we can use instead of String to improve the type safety here.


Identifier<T>

We can’t change the fact that the server is sending us Strings, but we can change how those strings are represented locally using a wrapper and phantom types.

Usually when we define a generic type like Identifier<T> we also use that Telsewhere in the type, something like let value: T. However, when the Tis only present as part of the declaration, it is called a phantom type.

What’s the point then? Why make something generic if we aren’t using the type? Well, we actually are using the type, just not in the usual way. Let’s take a look:

Updating our models to use this new type, they become:

and our function now becomes:

We have now made it impossible to incorrectly pass a String. We must provide an Identifier<Author> instead; otherwise, it will not compile even though they are all still Strings underneath.

This is what makes phantom types so useful. In this instance we are adding type safety to an ordinary String using a generic placeholder. We can now use Identifier<T> for not only our Book and Author models but for any other model with an identifier as well.


Codable Support

We have introduced a new problem with our Identifier<T>. Codable, by default, uses the same structure as the type. This means the JSON representation of an identifier would be:

This is wrong: we still want the JSON representation to be a String so that it works seamlessly with our API. Let’s fix the Codable behavior as follows:

Now when we convert between the model and JSON, it will be a regular Stringrather than a nested object.


Adding New Data to the API

Listing books and authors is working really well. Now it’s time to allow our users to submit new entries. The only problem is our API is responsible for determining the identifiers of new data, so we want to send JSON containing everything but the object identifier.

There are two different ways we could tackle this:

  • We could maintain a separate model that excludes the Identifier. This would be tedious and fragile, but perhaps we could leverage a codegen solution to help? This is a big dependency to add if you aren’t already using one, though.
  • We could provide a dummy identifier and remove it from the JSON before sending it to our API. This isn’t a very nice solution, though — using dummy values in production code seems like just begging to break and/or corrupt things.

Since we are creating new types today, let’s look at another one that can be used to solve this problem.


Identified<T>

What we need is a way to define two versions of our models: one with an Identifier when data is sent down and one without an Identifier when we send data up.

We can’t use the type system to remove properties … but we can use it to add them.

Using this type we can remove the identifier property from our models:

We can then define the two versions of a model we need. When we are receiving books from the API we can use Identified<Book> for each instance. When we want to add a new book we can simply use Book as is.

Having a type like Identified<T> gives us the flexibility we want without needing to maintain parallel models or hack out unwanted values before sending data.


Codable Support

As with Identifier<T> the default Codable behavior for Identified<T>would result in the wrong JSON:

We need to fix the Codable behavior so everything is still flattened when in its JSON form:

The AnyCodingKey type is used to allow us to dynamically decode certain parts of the data without needing all new types:


Conclusion

There are many ways to skin a cat, but hopefully this has shown a couple of interesting ways to solve some common problems using wrapper types and some pretty nifty Swift features.

There are a lot of additions that can be made to improve the ergonomics of these types, such as:

  • ExpressibleBy* conformance
  • Supporting values other than String

But I’ll leave these as an exercise for the reader. 🤘

You can grab the playground-ready code here.

If you have any feedback, leave a note or a reply on this post.