Using protocols to associate types
Today I’ve used Protocols to make a generic function only accept a certain number of types. The interesting part is that the only thing the protocol has is an associatedtype
, there are no other requirements, which may seem a little weird at first.
In the Lifeworks codebase we use Nimble to write our expectations and we have some custom matchers for some specific scenarios. The nice thing about these matchers is that they are just free functions than return a Predicate
.
In some tests we have checks to make sure that what we sent to our API is correct. That part of the code relies on Mockingjay to intercept and stub the requests, and with a custom matcher we can compare the body we’re sending.
The expectation code needs to check that an original typed Dictionary/Array equals to the blob of Data (a.k.a. JSON) we are sending. One approach would be to check the string representation of the JSON and the dictionary/array, but that’s not reliable as the hash of the Dictionary is not stable.
Instead we use Nimble matchers that check the equality of the Dictionary and Array.
To make this work, in one side we need to cast our values to NSDictionary:
let expected = expectedValue as NSDictionary?
on the other we need to create a NSDictionary from the JSON blob of Data:
let jsonDictionary = (try? JSONSerialization.jsonObject(with: jsonData, options: [])) as? NSDictionary
with this you can then easily just compare them with Nimble:
expect(jsonDictionary).to(expected)
We have this logic nicely wrapped in a function that we can use inside our custom Mockinjay stub
func equalDictionary(_ expectedValue: Dictionary<String, Any>?) -> Predicate<Data> {
let expected = expectedValue as NSDictionary?
return equal(expected).contramap({ (jsonData) -> NSDictionary? in
let jsonDictionary = (try? JSONSerialization.jsonObject(with: jsonData, options: [])) as? NSDictionary
return jsonDictionary
})
}
http(.patch, uri: "/endpoint", bodyCheck: { body in
expect(body).to(equalDictionary(parameters))
return true
})
This worked fine for Dictionary but in some endpoints we upload an Array as the root object of the JSON. The initial fix I did was to just overload the `
http(.patch, uri: "/endpoint", bodyCheck: { body in
expect(body).to(equalDictionary(parameters))
return true
})
The problem
This worked fine for Dictionary but in some endpoints we upload an Array as the root object of the JSON. The initial fix I did was to just add another function for Array. The problem is that this was too limiting as it mean that any further composition of this check would force me to overload the rest of functions. Overloads shouldn’t be used lightly as they quickly explode.
I preferred to have a single generic function for it, the tricky part was restricting it to only accept parameters that worked as JSON. Basically it should only accept Dictionary or Array.
Protocols to the rescue
If Swift had Unions that would have been my initial approach:
func equalJSON(_ expectedValue: Dictionary | Array) -> Predicate<Data> {
...
}
But of course, we can simulate that with a protocol.
protocol AsJSONRoot {}
extension Dictionary: AsJSONRoot {}
extension Array: AsJSONRoot {}
func equalJSON<T: AsJSONRoot>(_ expectedValue: T?) -> Predicate<Data> {
...
}
That’s something really easy to do in Swift and quite powerful. It ensures the function can only be used with the types I want.
But if you pay close attention to the function implementation, is not enough. And that’s because what we really need are NSDictionary
and NSArray
to work with JSONSerialization
but also to Nimble matcher.
Nimble can work with Dictionary and Array but that starts going the rabbit whole of constraining the protocols to Equatable.
associatedtype to the rescue
The solution to our problem is to let the compiler know that Array
is related to NSArray
and the same for dictionaries. Luckily for us Swift protocols allow us to do exactly that with associatedtype
.
protocol AsJSONRoot {
associatedtype JSONRoot: Equatable
}
extension Dictionary: AsJSONRoot {
typealias JSONRoot = NSDictionary
}
extension Array: AsJSONRoot {
typealias JSONRoot = NSArray
}
It may seem a little weird because the protocol is empty and only has an associatedtype
, but only with this change we can generalise our function to work with both types and interoperate with JSON.
func equalJSON<T: AsJSONRoot>(_ expectedValue: T?) -> Predicate<Data> {
let expected = expectedValue as? T.JSONRoot
return equal(expected).contramap({ (jsonData) -> T.JSONRoot? in
let jsonDictionary = (try? JSONSerialization.jsonObject(with: jsonData, options: [])) as? T.JSONRoot
return jsonDictionary
})
}
Gotta love the type system! 💕