JSON Handling
November 08, 2018 -The Problem
Let's say you have a handful of items in your code where the items seem quite similar, but are slightly different. For example, bus tickets: You might have paper tickets, monthly passes, and senior discount cards.
struct Paper {
let validUntil: Date
let currency: String
let price: Double
}
struct Monthly {
let validUntil: Date
}
struct SeniorDiscount {
let validUntil: Date
let age: Int
}
Let's say you're making the program that reads these different kinds of tickets when the bus driver scans them. The driver doesn't care about the details, they just want to know if the ticket is valid and maybe what kind of ticket it is. We do need to store the different details to send to the server though.
Since you're hip and writing things in Swift, you might first try to make a protocol which defines the common bits between them. You might also add an enumeration for the type because that's important information too.
enum TicketType {
case paper
case monthly
case seniorDiscount
}
protocol Ticket {
var validUntil: Date
var type: TicketType
}
This unfortunately does not work though. Since the Codable
protocol in Swift is generic, you cannot simply encode the type Array<Ticket>
. Here's how I solved the problem on a similar problem.
The Solution
Although each of these tickets are technically different things, they're all tickets. I could have made these classes and use subclassing for this, but subclassing for data feels weird to me. There's no behavior here, just data that needs to be stored and partly checked. I chose instead to make a single type that stores all the data for all the kinds of tickets. This method comes with some benefits:
- Uses the type enum you defined already, so defining a new type will generate a compiler warning anywhere you aren't considering this new case. A new sub-class or other kind of type would not generate the same warning.
- Makes testing simpler.
enum Value {
case date(Date)
case string(String)
case integer(Int)
case floatingPoint(Double)
}
struct Ticket {
let validUntil: Date
let type: TicketType
let userInfo: [String: Value]
}
This single struct can store the information for all types of ticket. Since the app only really cares about what kind of ticket it is and when it stops being valid, the rest can be tucked into a dictionary so we don't lose the information when we send to the server. There's precedent for this type of design all over Cocoa. NSError
and NSNotification
immediately come to mind as objects that work like this.
Constructing new tickets now is a bit tedious, so it's useful to make convenient constructors for each type:
extension Ticket {
static func paper(validUntil: Date, currency: String, price: Double) {
return Ticket(validUntil: validUntil, type: .paper, userInfo: [
"currency": .string(currency),
"price": .floatingPoint(price)
])
}
}
This gives similar benefit to a subclass or multiple-struct type organization. There's one single place to construct a paper ticket and make sure you don't include the necessary values. Since this is all the same type though, we don't need to also define how this paper ticket encodes and decodes from JSON.
All that's left is to make Ticket
and Value
conform to Codable
. This is fine for Ticket
, but Value
has some trouble. In JSON, there is no difference between an int or a float or a double. JSON only has a few types, one of them is "some number", so when you decode it takes a little bit to tell ints apart from floating point values. I solved this like this:
if let value = try? container.decode(Double.self), value.rounded() == value {
self = .int(Int(value))
}
A JSONDecoder
will allow you to decode floating point values as Int
and just quietly remove anything after the decimal. This will lead to some confusion if you don't specifically consider and test for it.
This makes testing simpler overall because you no longer need to test encoding and decoding each of your ticket types. If you manually create a ticket with each kind of Value
(or seperately test that each kind of Value
can be encoded properly), then a single test that checks encoding and decoding from JSON is sufficient to be confident your server will be happy with these tickets.
Conclusion
So there you have it, a refactoring that saved me quite some headache on a project that I think leaves the codebase in a nicer place. I think it's important when you're working in codebases to consider that sometimes a reorganization will make things much nicer rather than trying to patch a confusing implementation.