The outstanding article Destroy All Ifs does an excellent job describing the mechanism of Inversion of Control in Haskell. Unfortunately while Haskell is a beautiful language, it can often be off-putting for people who want to get things done without all the academic mumbo-jumbo. Since I would also like to get things done I think it’s worth exploring the concept of Inversion of Control in standard JavaScript.
What is Inversion of Control
Inversion of Control is the method by which frameworks are built. It is a mechanism for injecting new behaviors into an existing system. That sounds pretty abstract, so let’s look at an example.
const getWheatBread = (numSlices) => Array(numSlices).fill("wheat");
const getWhiteBread = (numSlices) => Array(numSlices).fill("white");
const makeToast = (isWheat, hasButter, hasJam) => {
var bread = isWheat ? getWheatBread(1) : getWhiteBread(1);
bread = bread.map((slice) => slice + " toasted")
if(hasButter){
bread = bread.map((slice) => slice + " butter")
}
if(hasJam){
bread = bread.map((slice) => slice + " jam")
}
return bread;
};
makeToast(true, true, true)
Here we have defined a protocol for making toast. The protocol is
- Get the bread
- Toast it
- Maybe add butter
- Maybe add jam
There is some trouble here. First, what the heck is makeToast(true, true, true)
? This is very difficult to read, and very easy to get wrong. Second, it’s not very extensible at all. What if we want to specify raspberry jam, or strawberry? We could add more booleans, but that seems like it would get quickly out of hand. Let’s try out this Inversion of Control thing that everyone is so hot about.
Attempt #1
const makeToast = (isWheat, hasButter, applyJam) => {
var bread = isWheat ? getWheatBread(1) : getWhiteBread(1);
bread = bread.map((slice) => slice + " toasted");
if(hasButter){
bread = bread.map((slice) => slice + " butter");
}
bread = bread.map(applyJam);
return bread;
};
makeToast(true, true, (slice) => slice + " raspberry jam");
Nice! We've made the application of jam dynamic, so we can add any kind of jam we want. But what if we want to toast up some rye bread, or try out a new buttering technique? Let’s take it a step further and invert the rest of the steps as well.
Attempt #2
const getWheatBread = (numSlices) => () => Array(numSlices).fill("wheat");
const getRyeBread = (numSlices) => () => Array(numSlices).fill("rye");
const makeToast = (getBread, applyButter, applyJam) => {
var bread = getBread();
bread = bread.map((slice) => slice + " toasted");
bread = bread.map(applyButter)
bread = bread.map(applyJam)
return bread;
};
makeToast(
getRyeBread(1),
(slice) => {
busyWait(5); // multiply numbers for 5 minutes so the computer will heat up and soften the butter
return slice + " butter";
},
(slice) => slice + " raspberry jam")
Ok great, now we can pass in different behaviors! We've decided that toasting will always work the same way, so we haven't inverted control of it. This is now much more extensible, and it’s much easier to understand what the parameters do. Let’s clean this up a little more.
Attempt #3
const makeToast = (getBread, applyButter, applyJam) =>
getBread()
.map((slice) => slice + " toasted")
.map(applyButter)
.map(applyJam)
Neat. There is a clear separation between things that can change behavior and things that can't. Let’s take another look at the protocol we defined at the beginning:
- Get the bread
- Toast it
- Maybe add butter
- Maybe add jam
Our structure is still in place, but each piece can be customized to how we need it.
Testing
One last thing. Getting our bread might require that we go out to the BreadService. That’s going to be slow, and jeeze who wants to stand up a BreadService just to be able to test our toasting function? What if instead, we injected the getFakeBread
function when we're running our tests?
const getFakeBread = (numSlices) => () => ["fake"];
it('should make some toast', async function() {
expect(makeToast(
getFakeBread(),
doButter,
doStrawberry)
).to.eql(["fake toasted butter strawberry jam"]);
})
Awesome. Let's take stock of what we've gained.
- We have defined which things in our protocol can change, and which things can't
- We can inject any behavior we want into the protocol, as long as the function conforms to the expected signature
- We can easily test our protocol
- Our code is much easier to understand and get right, since we are explicit about what behaviors we want
Victory!
Top comments (2)
What if you wanted to accommodate different toast-making processes, rather than having one constant method of doing so. Maybe I want to make kid toast, which involves cutting off the edges, but only after I've applied the butter and the jam. Or maybe when I'm making low-carb toast, I can't use any jam.
How would you incorporate those rules into the available injections?
Great questions!