Dependency Injection and Default Parameters
One Way to Make Your Programs More Flexible
I've been working on lessons for a new course recently, and one of the the lessons I created inspired me to share one of my favorite techniques for creating flexible functions: dependency injection and default parameters. Let's dig in.
Dependency injection
Now, I don't have a computer science degree, so my understanding of what "dependency injection" is might be a little different than yours, but allow me to explain how I understand it so we're on the same page for the rest of the blog post.
Let's start by coming to agreement on what a "dependency" is. Let's say I have the following function:
function formatString(str) {
return capitalize(str)
}
This is a simple, well named function. It formats a string. But the part I want to focus on for a moment is the use of capitalize
. What is capitalize
in this situation?
It is a dependency.
formatString
depends on capitalize
(for now). If capitalize
isn't in scope for formatString
, the program falls apart and throws an error. In this way, anything a function
1 depends on is a dependency.
This, of course, begs the next question: what does it mean to "inject" a dependency. Let's modify our formatString
function a little bit and find out.
Instead of relying on capitalize
to be in the same scope, let's make it a parameter of the function and pass capitalize
in as an argument.
function formatString(str, formatter) {
return formatter(str)
}
const capitalizedHello = formatString('hello', formatter)
console.log(capitalizedHello) // 'Hello'
Now the formatter
of our formatString
function is passed in as an argument. This allows us to pass in other formatter
s as well.
const scream = str => [...str].map(x => x.toUpperCase()).join('')
const snakeCase = str => str.replace(/\s/g, '_')
const str = 'hello world'
formatString(str, scream) // HELLO WORLD
formatString(str, snakeCase) // hello_world
But what happens if we forget to pass in a formatter
?
\Cue the womp womp womp noises* ๐บ
It falls apart, right? We can solve this with a default parameter.
Default parameters
JavaScript functions can be variadic, meaning they can be called with a variable number of arguments. When a function is called without an argument for a particular parameter, that parameter is assigned undefined
. However, if this is undesirable behavior, we can define defaults for these unassigned parameters using default parameters.
In the case of our formatString
function, we can make use of the identity
function.
const identity = x => x
The identity
function simply returns whatever is passed in. Give it a string. Get that string right back. It's perfect for a default parameter in our formatString
function. 2
function formatString(str, formatter = identity) {
return formatter(str)
}
formatString('hello world') // hello world
Now, we don't need to provide a dependency if the default one suits our needs. Are you starting to see how this can bake flexibility into the programs you write?
Practical example
I'm currently working on a course to help people understand Array.reduce()
better. It confuses a lot of people, and if you're one of them, know help is coming.
In the course, we do a lot of exercises using reduce
to build up our muscle memory. One of the more practical exercises is a counts
function. It receives an array, and counts the items in the array based on some criteria. The question is, how do we determine the criteria for counting the items?
In the case of primitive values, such as Number
s, String
s and Boolean
s, the criteria is fairly simple: use the item itself. Let's start building this counts
function, but with a for
loop. Don't want to spoil all my course material:
function counts(items) {
const result = {}
for (const item of items) {
if (result[item] === undefined) {
result[item] = 0
}
result[item]++
}
return result
}
Now if we call counts
on some arrays with primitives, we'd get results like these:
counts([true, false, true, true, false, false, true])
// { true: 4, false: 3 }
counts([1, 2, 2, 3, 3, 3])
// { '1': 1, '2': 2, '3': 3}
counts(['apple', 'banana', 'apple', 'orange', 'orange', 'apple'])
// { apple: 3, banana: 1, orange: 2}
That works great, but how do we handle situations where our items aren't primitives? Or situations where we might want to do something more complicated than use the item
itself as the key? 3
You guessed it. Dependency injection and a default parameter.
Let's add an argument to our counts
function, a getKey
callback function that will be passed the item
(and some other arguments). This function will determine the key
to be used in our counts
function for us.
function counts(items, getKey /* what should the default parameter be? */) {
const result = {}
// Notice the change we make with entries()
for (const [index, item] of items.entries()) {
// This is the same signature as the callback for .map or .filter
const key = getKey(item, index, items)
if (result[key] === undefined) {
result[key] = 0
}
result[key]++
}
return result
}
Now, if we have a more complicated array of items, or simply want to do something more complicated with a primitive array of items, we can. Let's say I'm counting inventory of some clothing. I want to be able to generate counts
based on the color
of the shirt and the size
of the shirt.
const shirts = [
{ color: 'red', size: 'M' },
{ color: 'blue', size: 'M' },
{ color: 'red', size: 'S' },
{ color: 'red', size: 'L' },
{ color: 'blue', size: 'S' },
]
counts(shirts, item => item.color)
// { red: 3, blue: 2 }
counts(shirts, item => item.size)
// { L: 1, M: 2, S: 2 }
We can even use it to do something like count how many even and odd numbers are in a list.
const nums = [1, 2, 3, 4, 5, 6, 7, 8, 9]
counts(nums, x => (x % 2 ? 'odd' : 'even'))
// { odd: 5, even: 4 }
Injecting the getKey
dependency makes this function a lot more flexible. We just have one thing remaining. Did you figure out what the default getKey
function should be?
That's right. It should be identity
. Our final counts
function looks like this:
function counts(items, getKey = identity) {
const result = {}
for (const [index, item] of items.entries()) {
const key = getKey(item, index, items)
if (result[key] === undefined) {
result[key] = 0
}
result[key]++
}
return result
}
Summary
Functions can be made more flexible by passing in their dependencies rather than relying on them being available in their scope. We can set good defaults to handle most cases, and then override that parameter with argument of our own which injects the dependency. This pattern can be seen in something as small as the examples here, to much bigger systems.