Non-nominal types workaround for domain modeling
One of the many things I love about working with The Composable Architecture is how it encourages the developer to focus on domain modeling. I think that properly designing the models or entities of a domain is one of the biggest contributors into good code, and the lack of it makes everything way harder than it should be. Thankfully Swift is a great language that allows us to express almost everything we need to in a very strict and concise way, another reason the language of your choice matters most than people pretends.
So the topic I will discuss here is not really specific to the TCA, but more about domain modeling and types. Let’s start by exposing the issue:
Let’s imagine we want to model the result of some side effect, like an API call, that can succeed and fail. We can easily do this with Swift’s Result
type, but in this case we do not care about the success data at all, only about the failure. In other words, we want to know which specific error occurred, but if it succeeded we don’t want to know anything about the success data.
Of course if you have done any meaningful coding with TCA this will feel very familiar, and indeed is where I find this more often, with its other variations of I only care about success data and not the error, I don’t care about any of the two, etc… But let’s ignore that for now and see what the issue is and why is so important to model this correctly.
Algebra
What? Math? Seriously? Yes! It’s actually the easiest way of realizing what you are doing when modeling a solution. Don’t fear, it’s very simple.
If you want to learn more about Algebraic Data Types I really recommend subsribing to Pointfree (get one month free with this link)
Let’s first think how many potential scenarios the most basic design gives us:
Result<Int, Error>
With this type, we have n
possibilities for the integer in case of success, and m
possibilities for the error in case of failure. (let’s assume Int
is what the side effect responds with). These are n + m
cases we would have to deal with, too many! Remember, we don’t care about the success data.
So if we don’t care about the success and only about the error, maybe we can ditch the Result type completely and just use the Error.
Error
That will give us m
possibilities, but we are lacking a way to know if it succeeded. We want to know that it succeeded, but not about its specific data. But Swift already provides with a tool for it, Optional
.
Optional<Error> // Error?
Now we have m
from the Error
and 1
from the optional nil case, so m + 1
. Exactly the amount of cases we wanted!
Simple right?
But let’s say we want to still use the Result
type for consistency with the rest of the code. How can we use that type, which requires a Success
and still keep the same m + 1
? We need a type that only gives us 1 option, and again, we have one. Swift calls it Void
or ()
(the empty tuple), called Unit in other languages. With that knowledge, let’s use Result
again.
Result<Void, Error>
Thanks to Void
we can use the Result
type and still have 1 + m
cases to deal with.
This may seem like a minor thing, after all, you could just use the original data (Int
) and just ignore it on your code. But that’s a slippery slope that makes your code harder to reason about. When your future self comes back and reads the code, the types will explain the story of what is happening without having to read the code. The compiler will make you remember the decisions you took when you already forgot them. Such is the power of properly modeling domains with a proper type system.
The limitations
This is all nice but we need to use this response in another type, in the case of the TCA we need to embed this in an Action enum, but it could be any other type you use to carry data around.
This is how it would look if we cared about the success data:
struct ApiResponse {
let response: Result<Int, Error>
}
All well so far. But let’s make this type more realistic and make it conform to Equatable
, which is something we need most of the time.
struct ApiResponse: Equatable { // Type 'ApiResponse' does not conform to protocol 'Equatable'
Boom! 💥 The compiler can’t synthesise the conformance because our Result
type is not equatable. Int
is obviously fine, the problem is the Error
.
The reason for this is a rabbit whole we don’t have time to get into now, but there are multiple solutions for it. Thankfully PointFree has already provided one for the users of the TCA in the form of TaskResult
(docs) so we can ignore this exact problem and move on to the one I want to discuss.
struct ApiResponse: Equatable {
let response: TaskResult<Int>
}
Now the compiler is happy again. Keep in mind that even if we don’t see the Error
type anymore, the failure case is still there case failure(Error)
so we still have the data we care about.
But we are still using the awful Int
, let’s switch back to use our lovely Void
.
struct ApiResponse: Equatable { // Type 'ApiResponse' does not conform to protocol 'Equatable'
let response: TaskResult<Void>
}
💥 Here we are again! Now it’s Void
that is not Equatable
and if you know a bit about Swift you know why.
Void
is a non-nominal type, which in simple terms it means there is not a name you can use to refer to it in some language features. One such feature is protocol conformance, which allows types to adopt a set of behaviors defined by a protocol, in this case equatability. Because Void is not a named type, Swift does not inherently know whether it conforms to any given protocol. Although we might want to make Void conform to Equatable ourselves by defining an extension, we can’t do it because we cannot reference it by name! Therefore, we can’t retroactively make Void conform to any protocol. 😞
extension Void: Equatable {} // Non-nominal type 'Void' cannot be extended
So if we can’t use Void
, maybe we can use the alternative of using an Optional
?
Well, that brings us back the problem with Error
that we solved with TaskResult
so is not a solution.
struct ApiResponse: Equatable { // Type 'ApiResponse' does not conform to protocol 'Equatable'
let response: Error?
}
EquatableVoid
My favorite solution to this problem is just to make Void
a nominal type. Of course you can’t make the existing one but you can just make your own. Remember how we described Void as the empty tuple ()
? A tuple is an anonymous product type, but we can use a named product type instead.
struct EquatableVoid: Equatable {}
Our EquatableVoid
it has the same shape a Void
, it only has 1 possible value which we can get by calling the initializer EquatableVoid()
. Is not as nice as just ()
but such is the price we need to pay.
With this new type, we can change our response and finally have what we want:
struct ApiResponse: Equatable {
let response: TaskResult<EquatableVoid>
}
This gives us 1 + m
cases, no more, no less. We have expressed exactly our domain.
We can use our types like so:
let ok = ApiResponse(response: TaskResult<EquatableVoid>.success(EquatableVoid()))
let ko = ApiResponse(response: TaskResult<EquatableVoid>.failure(Badabum.granBadabum))
And work with them in a switch statement exactly like we wanted:
switch response {
case .success(let void):
// not much you can do here, you only have 1 possible value that is always the same
case .failure(let error):
// but here you have all errors so you can work with them as you please
}
Other unsatisfactory solutions
There are, of course, other solutions that have other tradeoffs, so as usual I give you the tools so you can take the decision on your own.
You could use the optional alternative by using a specific error type that was equatable: SpecificEquatableErrorForYourDomain?
. That would work but then you need to reconcile your specific error type with Swift’s untyped errors. With time I’ve learned to embrace Swift’s error system and stop fighting it, so I don’t recommend this. Is a fix for this exact part of the code, but you will have to write patches in other parts to deal with any errors and default cases.
Another solution would be to use NSError
, which is convertible to Error
and is equatable. I tend to run away from it. I’m fine receiving it from Objective-C APIs but I want my Swift code to be Swift code. NSError
loses the type safety at the usage side. You can’t cast it to a specific type but need to rely on strings and integers. It’s not a tradeoff I’m willing to make.
And I imagine there are other ways of solving this with different tradeoffs, but I’m not aware of one that gives me what I want in a better way :)
Other usages of this technique
This technique is not something new that I only use for this case, in fact this is the third occurrence in my codebase of this solution.
In our project, we use the TCA to its fullest and I made a generic loading system that we are very happy with. The closure that lets you customize how to get the data receives a parameter that can be injected from other domains. Think of an ID that you need to fetch a user, for example. But not all screens need parameters, so you would want to define the generic type as Void
, but again it needs to be equatable. In this case, I employ another trick that let developers avoid even constructing this type at all.
public typealias NoParameter = _NoParameter?
public struct _NoParameter: Equatable {}
By using NoParameter
as the generic type, developers (in fact our library has this default cause supported) can just pass nil when constructing the domain and they don’t have to deal with the parameter in their implementation.
I should talk about our generic loading system because I think is pretty neat. Ping me if it’s something you would be interested in :)
Another place where this pattern occurred is in the API library I implemented, which uses Swift’s generic type system to make it very simple to define endpoints. In an endpoint, there is a generic parameter that defines the response model you want to decode, but of course, sometimes you don’t really want to decode anything. The same goes for the generic parameter that specifies which parameter to send to the API. For that, again, you would love to use Void
but it’s not Decodable
or Encodable
so it won’t work.
public struct Ignore: Equatable, Codable {}
Maybe I should think about making a SuperVoid
that covers all the cases :)
Conclusion
Domain modeling matters, and having a language with a type system that supports us when designing, is crucial. Swift gets very far, but it still has some limitations with non-nominal types that thankfully can be solved by defining our own types to cover for these cases.