January 02, 2019
Let’s say we wanted to represent a water tap. This tap will have two knobs, one to control cold water output and another one for warm water. To keep things relatively simple, each knob will be either closed or open all the way.
If I had to represent this tap, I would write this function (over-simplified with regards to the output):
// Definition
function tap(coldKnobOpen, warmKnobOpen) {
if (coldKnobOpen && warmKnobOpen) {
// Both knobs are open
return 'lukewarm water'
}
if (coldKnobOpen) {
// Only the cold knob is open
return 'cold water'
}
if (warmKnobOpen) {
// Only the warm knob is open
return 'warm water'
}
// Both knobs are closed
return 'no water'
}
// Usage
tap(false, false) // With both knobs closed
// 'no water'
tap(false, true) // Opening the warm water knob
// 'warm water'
tap(true, true) // Opening both knobs
// 'lukewarm water'
tap(true, false) // Opening the cold knob
// 'cold water'
What happens when you open the cold knob all the way? You’d expect to get cold water, and with this function, that is what you would get. But in the physical world, sometimes when I want a glass of water, I open the cold knob and surprisingly get warm water, which is never a nice surprise as I find drinking warm water really unpleasant.
How does that happen? Well, someone used the tap before me and made warm water flow out of it, which then remained in the pipe until I used the tap and released the water. It turns out the tap holds an internal state - the temperature of the water in the pipe - which impacts the output. So it should look more like the following (this time, it’s also over-simplified with regards to flow rate):
// Definition
function createTap() {
let waterInThePipe = 'cold water'
return function(coldKnobOpen, warmKnobOpen) {
// Both knobs are closed
if (!coldKnobOpen && !warmKnobOpen) {
return 'no water'
}
const output = waterInThePipe
waterInThePipe = newWater(coldKnobOpen, warmKnobOpen)
return output
}
}
function newWater(coldKnobOpen, warmKnobOpen) {
if (coldKnobOpen && warmKnobOpen) {
// Both knobs are open
return 'lukewarm water'
}
if (coldKnobOpen) {
// Only the cold knob is open
return 'cold water'
}
// Only the warm knob is open
return 'warm water'
}
// Usage
const tap = createTap()
tap(false, false) // With both knobs closed
// 'no water'
tap(true, false) // Opening the cold water knob
// 'cold water'
tap(false, true) // Opening the warm water knob
// 'cold water'
tap(true, true) // Opening both knobs
// 'warm water'
tap(true, false) // Opening the cold water knob
// 'lukewarm water'
This is more accurate and allows me to reproduce what happened to me when I accidentally drank warm water. But I don’t like this API for several reasons:
tap
. If I call tap(false, true)
, maybe I’ll get warm water, but maybe I won’t, and I have to test the result in the code. With a physical tap, I usually have to put my finger under the tap and get it wet in order to know the water’s temperature.tap
twice, like tap(false, true); const result = tap(false, true)
, disregarding the first one. With a physical tap, I can, and often have to, let the water flow for a while, which is a waste of water.For these reasons, I don’t like this API and I don’t like my physical tap. Have you ever seen documentation for a water tap? I’m sure it exists somewhere, but I haven’t seen one.
One way we could make this better, is to make the implicit state explicit.
// Definition
const initialWaterInPipe = 'cold water'
function tap(coldKnobOpen, warmKnobOpen, waterInPipe) {
// Both knobs are closed
if (!coldKnobOpen && !warmKnobOpen) {
return {
water: 'no water',
waterInPipe: waterInPipe,
}
}
return {
water: waterInPipe,
waterInPipe: newWater(coldKnobOpen, warmKnobOpen),
}
}
function newWater(coldKnobOpen, warmKnobOpen) {
if (coldKnobOpen && warmKnobOpen) {
// Both knobs are open
return 'lukewarm water'
}
if (coldKnobOpen) {
// Only the cold knob is open
return 'cold water'
}
// Only the warm knob is open
return 'warm water'
}
// Usage
let output = tap(false, false, initialWaterInPipe) // With both knobs closed
// output.water === 'no water'
output = tap(true, false, output) // Opening the cold water knob
// output.water === 'cold water'
output = tap(false, true, output.waterInPipe) // Opening the warm water knob
// output.water === 'cold water'
output = tap(true, true, output.waterInPipe) // Opening both knobs
// output.water === 'warm water'
output = tap(true, false, output.waterInPipe) // Opening the cold water knob
// output.water === 'lukewarm water'
How does this compare to the problems I mentioned before?
tap
: Since we now all the inputs, we now can predict the output reliably.tap
twice: I can now predict the output, so this is now irrelevant. I might still have to let the water flow in order to get warm water out of a previously cold tap though.Obviously, there are a few trade-offs.
waterInPipe
value somewhere. In my opinion, the impact on your code is not that big and probably worth the trade. We would have needed to store the tap
function with the previous implementation too.waterInPipe
value.Of the APIs written here, my favorite is, by far, the first one, the one without state. I would be much happier if my tap poured warm water directly when what I want is warm water. If you can design your system to be without state, please do so.
If not, because the system you’re handling really needs to hold some state, make it explicit, even if it’s not understandable by the user. Just knowing there is a state removes surprises, like warm water in my glass.
Written by Jeroen Engels, author of elm-review. If you like what you read or what I made, you can follow me on BlueSky/Mastodon or sponsor me so that I can one day do more of this full-time ❤️.