Dependency Injection and Default Parameters

One Way to Make Your Programs More Flexible

ยท

6 min read

Dependency Injection and Default Parameters

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 formatters 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 Numbers, Strings and Booleans, 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.

ย