I find myself running up against the same pattern in my designs where I start with a type with a few data constructors, eventually want to be able to type against those data constructors and thus split them into their own types, just to then have to increase the verbosity of other parts of the program by needing to use Either or another tagged-union for situations where I still need to represent multiple of these types (namely collections).
I am hoping someone can point me to a better way of accomplishing what I'm trying to do. Let me start with a simple example. I am modeling a testing system, where you can have nested test suites which eventually end in tests. So, something like this:
data Node =
Test { source::string }
Suite { title::string, children::[Node] }
So, pretty simple so far, essentially a fancy Tree/Leaf declaration. However, I quickly realize that I want to be able to make functions that take Tests specifically. As such, I'll now split it up as so:
data Test = Test { source::string }
data Suite = Suite { title::string, children::[Either Test Suite] }
Alternatively I might roll a "custom" Either (especially if the example is more complicated and has more than 2 options), say something like:
data Node =
fromTest Test
fromSuite Suite
So, already its pretty unfortunate that just to be able to have a Suite
that can have a combination of Suites or Tests I end up with a weird overhead Either
class (whether it be with an actual Either
or a custom one). If I use existential type classes, I could get away with making both Test
and Suite
derive "Node_" and then have Suite
own a List of said Node
s. Coproducts would allow something similar, where I'd essentially do the same Either
strategy without the verbosity of the tags.
Allow me to expand now with a more complex example. The results of the tests can be either Skipped (the test was disabled), Success, Failure, or Omitted (the test or suite could not be run due to a previous failure). Again, I originally started with something like this:
data Result = Success | Omitted | Failure | Skipped
data ResultTree =
Tree { children::[ResultTree], result::Result } |
Leaf Result
But I quickly realized I wanted to be able to write functions that took specific results, and more importantly, have the type itself enforce the ownership properties: A successful suite must only own Success or Skipped children, Failure's children can be anything, Omitted can only own Omitted, etc. So now I end up with something like this:
data Success = Success { children::[Either Success Skipped] }
data Failure = Failure { children::[AnyResult] }
data Omitted = Omitted { children::[Omitted] }
data Skipped = Skipped { children::[Skipped] }
data AnyResult =
fromSuccess Success |
fromFailure Failure |
fromOmitted Omitted |
fromSkipped Skipped
Again, I now have these weird "Wrapper" types like AnyResult
, but, I get type enforcement of something that used to only be enforced from runtime operation. Is there a better strategy to this that doesn't involve turning on features like existential type classes?