Wrapping functions in structs
18 Mar 2023We can use functions in Swift to bundle up behaviour and pass it around our application really easily. With functions we can sometimes hit specific usability examples, which can be resolved by wrapping our function in a struct. This post is 100% not a recommendation to wrap every function in a struct, but instead an examination of some cases where it might make sense.
Here’s an example of me wanting to hide a fairly complex interaction and make it more testable.
In this case I want to check if the device has authorised push notifications, to keep things simple I’m not bothered about the specifics of what settings are enabled but rather just whether the system is authorised or not.
I can boil this down to just injecting in a function of () async -> Bool
:
World.areNotificationsAuthorized = {
if case .authorized = await UNUserNotificationCenter.current().notificationSettings().authorizationStatus {
return true
} else {
return false
}
}
...
func performAuthorizationFlow(isAuthorized: () async -> Bool = World.areNotificationsAuthorized) {
if (await isAuthorized()) {
...
} else {
...
}
}
With this approach I can test this function by simply overriding the isAuthorized
function e.g.
performAuthorizationFlow(isAuthorized: { true })
// or
performAuthorizationFlow(isAuthorized: { false })
Arguably I could try and start doing some deeply nested mocking of types that I don’t own to verify that the code calls current()
followed by notificationSettings()
and then change the returned UNAuthorizationStatus
but that involves a whole other conversation about trade offs and what I care to test.
Problem 1
This example highlights the first problem with this approach.
The function performAuthorizationFlow
takes in a function of () async -> Bool
, which is pretty general.
There could be many functions in my system that have this shape, which means I could accidentally pass the wrong function.
This problem is not unique to function types and creating whole new types rather than type aliases is a common solution.
To get around this we can define a simple struct that holds onto the function like this:
struct NotificationAuthorizationStatus {
let run: () async -> Bool
}
With this in place we can update our code to
- World.areNotificationsAuthorized = {
+ World.areNotificationsAuthorized = NotificationAuthorizationStatus {
if case .authorized = await UNUserNotificationCenter.current().notificationSettings().authorizationStatus {
return true
} else {
return false
}
}
...
- func performAuthorizationFlow(isAuthorized: () async -> Bool = World.areNotificationsAuthorized) {
+ func performAuthorizationFlow(isAuthorized: NotificationAuthorizationStatus = World.areNotificationsAuthorized) {
- if (await isAuthorized()) {
+ if (await isAuthorized.run()) {
...
} else {
...
}
}
Now we have to pass a concrete type which removes the chance of accidentally passing the wrong function to our method.
Problem 2
With a bare function we don’t really have a nice namespace to work within.
In this example we have the default implementation of this authorization function that calls out to Apple’s framework.
At the moment this code is floating in the breeze being assigned to the World
object.
We can take inspiration from Point-Free’s work on protocol witnesses and add some convenience functions on our new namespace
struct NotificationAuthorizationStatus {
let run: () async -> Bool
static let live = NotificationAuthorizationStatus {
if case .authorized = await UNUserNotificationCenter.current().notificationSettings().authorizationStatus {
return true
} else {
return false
}
}
}
With this change our original set up changes like this:
- World.areNotificationsAuthorized = NotificationAuthorizationStatus {
- if case .authorized = await UNUserNotificationCenter.current().notificationSettings().authorizationStatus {
- return true
- } else {
- return false
- }
- }
+ World.areNotificationsAuthorized = .live
In our test target we could even add some convenience functions like this
extension NotificationAuthorizationStatus {
static let alwaysTrue = NotificationAuthorizationStatus { true }
static let alwaysFalse = NotificationAuthorizationStatus { false }
}
Problem 3
For the 3rd problem I’ll need a slightly different example (yes I could have used one example but repetition often helps cement ideas).
This is an issue of usability for the code calling the closure. Imagine we have the following block:
let completion: (String, String, String) -> Void
At the call site it would be pretty unclear what each of these arguments should be e.g. completion(?, ?, ?)
and we’d have to trace back through our code to find out.
We can improve this situation slightly by adding some documentation to the defintion so that we only need to navigate back through the code so far:
let completion: (_ email: String, _ forename: String, _ surname: String) -> Void
If we convert this to be wrapped by a struct we can see some options to improve this
struct UserCompletion {
private let completion: (_ email: String, _ forename: String, _ surname: String) -> Void
init(completion: @escaping (_ email: String, _ forename: String, _ surname: String) -> Void) {
self.completion = completion
}
func invoke(email: String, forename: String, surname: String) {
completion(email, forename, surname)
}
}
With the above our call site changes from the confusing invocation with no argument labels to a normal function call with argument labels (I didn’t even think of this benefit until my friend Ellen pointed it out)
- completion("[email protected]", "Paul", "Samuels")
+ completion.invoke(email: "[email protected]", forename: "Paul", surname: "Samuels")
It’s not strongly typed and we can still pass the wrong values in each argument position but at least the labels give some guidance.
There’s another enhancement that we can take advantage of here as the extra .invoke
is a bit annoying.
Instead if we change the name of our method invoke
to callAsFunction
then this makes our struct directly invokable:
- completion.invoke(email: "[email protected]", forename: "Paul", surname: "Samuels")
+ completion(email: "[email protected]", forename: "Paul", surname: "Samuels")
Conclusion
Swift is really expressive and has a lot of features to help organise code and make things stricter. In this post we was able to make it more difficult to pass the wrong function around, gave ourselves a namespace to place code in and made our call sites a little safer.
I won’t be going off on a code rewriting spree but it’s definitely useful to have another option in my bag of tricks.