Better Swift Codable Models Through Composition
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:
Codable features enable us to quickly create matching models:
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.
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
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
Author models but for any other model with an identifier as well.
We have introduced a new problem with our
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.
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.
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:
AnyCodingKey type is used to allow us to dynamically decode certain parts of the data without needing all new types:
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:
- Supporting values other than
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.