Boolean logic is central to many business rules. But sometimes it’s not enough to know whether a chain of conditions evaluates to true
or false
; we might want to know which of the conditions failed. F#, unsurprisingly, lets us model this in a very succinct, composable, and completely type-safe way. But there are pitfalls – sometimes, a seemingly simple and pure function just won’t cut it.
(Short on time? The three code blocks will give you the general gist, but you might miss out on some subtleties.)
Your perfect F# snowflake
You are a developer for an airline called Breaking Wind.1 You’re responsible for a system used at the boarding gate to check whether passengers are allowed to board. It’s implemented in F#, and at its core is this simple function:
// Flight -> Passenger -> bool let isBoardingAllowed flight pas = pas.IsOnPassengerList && pas.HasBoardingPass && not flight.IsOverbooked && not pas.IsTerrorist && (not pas.HasCriminalRecord || pas.HasDiplomaticImmunity)
All tests are green, there are no bugs in production, and the NSA would (figuratively and quite possibly literally) kill for the data retrieval system. (Thankfully that’s not your project.)
The problem
The issue is one of context. The returned value does not contain any indication of why a passenger might not be allowed to board, effectively refusing callers all the way up any knowledge about this fact. As illustrated by the image up top, the most helpful error message would simply be:
The operation is not allowed because a boolean expression evaluated to
false
.
This isn’t your problem, of course – all your code is implemented exactly according to specification, and thank the heavens the specification is so simple, because boolean logic is so very clean and succinct (at least with domain objects like these!). But while you’re relaxing in front of your screen admiring your perfect little snowflake, exchanges like the following are common at the boarding gate, and Breaking Wind’s boarding agent Judy hates them:
“Welcome sir, may I see your boarding pass?
“Of course, here you go.”
“Thank you, sir. Just a moment.”
(Judy’s screen flashes the word “DENIED” in large, friendly letters on a soothing blue bakground.)
“I’m sorry sir, it seems you’re not allowed to board this flight.”
“What? And why in the blazes might that be?”
“Very sorry sir, I’m not sure, our system doesn’t tell us. Might you perhaps have a criminal record?”
“A cri– well, I’ll be! How dare you! Never in my life have I heard such a preposterous –”
(The man turns around and leaves – rightfully so; subtlety never was Judy’s strong suit.)
“We hope you’ll choose Breaking Wind again in the future!” she calls after him as he hurries red-faced from the gate.
A first try
Breaking Wind receives many such complaints. After a few years, upper management starts to catch on, and they decide that the customer must be told the primary reason for being denied. They hand the task over to you.
You start considering how in the blazes you can implement this while keeping your sanity. You take a cursory glance at some design patterns such as the specification pattern and promptly start sweating as you realize you may have to extend your little snowflake to a 1500 line ungainly monster.
Just as you are about to curl up on the floor, a voice softly calls you from somewhere in the dark recesses of your mind:
“Use the Types, Luke! Let the Types guide you!”
(Yes, your name is Luke.)
“Of course!” you think. In F#, there’s always a simpler way. You give silent thanks to your personal Obi-Wan, crack your fingers, and start designing.
You figure that the different reasons can be modeled by a simple discriminated union, and that its cases have a natural priority: If John is not on the passenger list, Breaking Wind frankly don’t care whether or not he is in possession of a (stolen?) boarding pass. If Susan doesn’t have a boarding pass, it’s simply not relevant to her that the flight is overbooked. Et cetera.
You therefore need the union type, a function to get the primary reason, and – to preserve backwards compatibility for the time being – the same isBoardingAllowed
function you already have.
The most straightforward way to implement the type and first function might be like this:
type BoardingDeniedReason = | NotOnPassengerList | NoBoardingPass ... // Flight -> Passenger -> BoardingDeniedReason option let primaryBoardingDeniedReason flight pas = if not pas.IsOnPassengerList then Some NotOnPassengerList elif not pas.HasBoardingPass then Some NoBoardingPass ... else None
However, while this is a pure function, it’s not trivial to test. The first case is easy – you can just generate random Flight
and Passenger
objects and assert that if not pas.IsOnPassengerList
then you should get Some NotOnPassengerList
. But the second one is worse, because to test that path you have to pass in arguments that do not trigger the first path, i.e., you have to ensure that the pas.IsOnPassengerList = true
. Only then can you test pas.HasBoardingPass
against the NoBoardingPass
reason. And as you can see, it only gets worse for each new case.
And what if upper management changes their mind and wants all applicable reasons shown – or at least available to control other domain logic? Then this function won’t cut it at all; your logic must be completely rewritten.
In general, the implementation reeks a bit of imperative programming with all its if ... elif
stuff. It doesn’t feel very functional. But while “doesn’t feel functional” may make you suspicious, it doesn’t explain what’s wrong with this pure and seemingly simple function. What is actually going on?
Do, or do not: The F# way
“I am trying”, you whisper to yourself. As tears are welling up in your eyes, another voice echoes through your mind. “Matters it does not what you call it. Composability what you are missing is. Separate concerns you must.”
The blinding light of epiphany dazzles you. The reason for your troubles is that the simple, innocent-looking function is mixing no less than three separable concerns: The ordering of the reasons, the conditions for each reason, and the fact that only one reason should be returned.
It would be better to split these three concerns, so that the core is a function that only associates each reason with its particular condition, and where each reason-condition pair is completely separate from the others. This sounds like a job for pattern matching!
A simple and trivially testable way to implement this is using a function hasBoardingDeniedReason : Flight -> Passenger -> BoardingDeniedReason -> bool
that indicates whether the specified flight or passenger is denied boarding for the specified reason. To find the primary reason, you then simply start with a list of all reasons in descending order of precedence and check each in turn, stopping at the first reason you find.
type BoardingDeniedReason = | NotOnPassengerList | NoBoardingPass | FlightOverbooked | IsTerrorist | IsCriminal /// All possible denied reasons in descending order of precedence. static member AllSorted = [ NotOnPassengerList; NoBoardingPass; FlightOverbooked; IsTerrorist; IsCriminal ] // Flight -> Passenger -> BoardingDeniedReason -> bool let hasBoardingDeniedReason flight pas reason = match reason with | NotOnPassengerList -> not pas.IsOnPassengerList | MissingBoardingPass -> not pas.HasBoardingPass | FlightOverbooked -> flight.IsOverbooked | IsTerrorist -> pas.IsTerrorist | IsCriminal -> pas.HasCriminalRecord && not pas.HasDiplomaticImmunity // Flight -> Passenger -> BoardingDeniedReason option let primaryBoardingDeniedReason flight pas = BoardingDeniedReason.AllSorted |> List.tryFind (hasBoardingDeniedReason flight pas) // Flight -> Passenger -> bool let isBoardingAllowed flight pas = primaryBoardingDeniedReason flight pas |> Option.isNone
First a few notes on the code above:
- You might wonder why the list of reasons is not defined directly in
primaryBoardingDeniedReason
. First, the order of the reasons is a separate concern that should be easily testable, which it trivially is when defined on its own. Secondly, there is no compile-time guarantee that the list contains all reasons (which may not necessarily be desired; see the next point), and keeping the list close to the case definitions makes you more likely to remember updating the list when adding new reasons. - If you wanted, you could use a list expression in
AllSorted
to conditionally disable certain reasons altogether, e.g. using feature flags:[...; if FeatureFlags.checkTerrorist then yield IsTerrorist; ...]
. - You could also use reflection to generate all cases automatically. You’d never forget a case, but you’ll probably get a runtime error if you add a case with data (an entirely valid thing to do here and something you might need at some point), and you won’t be able to use list expressions as mentioned above.
Back to our story: Your refactoring hasn’t broken any existing code, and now you can start using primaryBoardingDeniedReason
instead of isBoardingAllowed
whenever you need to convey the reason why boarding is denied. You’ll probably agree that hasBoardingDeniedReason
is trivially testable, and the other functions are so boring once blessed by the compiler that I wouldn’t blame you if you skipped testing them.
And if upper management changes their mind and wants multiple reasons to show up, the changes at this level are trivial: You can write a function allBoardingDeniedReasons
that is identical to primaryBoardingDeniedReason
except for using List.filter
instead of List.tryFind
. You then get all relevant reasons (sorted by precedence) and can further filter/transform this list if needed. You can even rewrite primaryBoardingDeniedReason
and isBoardingAllowed
in terms of allBoardingDeniedReasons
, if you want (using List.tryHead
and List.isEmpty
).
The moral: Composability
Your new code is easily testable and understandable, and as you can see, it would be easy to change from indicating the primary reason to indicating all applicable reasons. This is because the different parts, in particular hasBoardingDeniedReason
, are much more composable. The function only does one thing – couple each reason to the condition for that reason. Good composability is easy to achieve when each part does only one thing. All your other functions are just composing simple pieces together like well-fitting Lego bricks. This is what F# is best at, and indeed pushes you towards – functional programming tends to make you fall into a pit of success.23
You check in your changes and deploy, and all is now well in the world. Judy screams in panic as her screen helpfully flashes the word “TERRORIST” in large, friendly letters on a soothing blue background, Breaking Wind is well on its way to taking over the aviation industry, nobody cares about your snowflake, and extreme poverty is finally eradicated. (The latter completely unrelated to your improvement, of course. Don’t flatter yourself.)
There is a reddit thread here.
Nobody really likes the name, but PR says changing it is bad for SEO. Breaking Wind makes up for it by serving free biscuits on every flight. They call them Air Biscuits. Some have tipped them off that this too is a rather unfortunate name, so they’re considering baking brownies instead.↩
Mark Seemann also has a blog post about the topic.↩
Though if you’re not used to functional programming, some deliberate mental shifts are bound to be required. I found Scott Wlaschin’s F# for fun and profit to be perhaps the best resource for learning to think functionally with F#.↩