Optional DI in JavaScript

Using dependency injection with default parameter values to make functions testable

Hajime Yamasaki Vukelic
Better Programming

--

Photo by John Barkiple on Unsplash

This was something that eluded me for a while since I went back to using global variables, and I kept thinking it was an either-or thing. Do I continue to use global variables, or write testable code. While attempting to solve some other issue, I accidentally discovered a technique that appears to be working pretty well in making code that uses global variables testable, so I’ll share it with you here.

Dependency injection is a design pattern that normally warrants a lengthy explanation in the context of statically typed OOP languages. In this article, I will show a simple version of the DI pattern that pertains to applications that use module-scope variables as module/system state and not the class-based OOP. In combination with default parameter values, we can implement a pattern I call “optional DI”. The result is a code that exhibits the characteristics of both tight and loose coupling depending on its usage.

The context

Before we go on to the code, I will try to set the scene first.

  • In this article, when I say “global variables”, I really mean “module-scope” variables. In JavaScript, these are not technically the same thing. Global variables are defined on the global object or by declaring a variable in a non-module script without the enclosing block or function, and are accessible to any code executing in the same tab. Module-scope variables are the ones that are defined outside of any function in the module, and they are private to the module. I will use the term “global variable” in this article to refer to “module-scope variables” because it’s shorter and generally similar in their usage.
  • When I say “global variables”, I do mean shared state, but not really. The state is shared across the code base or at least across the functions in the module but across threads, because JavaScript has no threads. This is not the same as the shared state in “shared state is evil” you keep hearing about from the programmers using multi-threaded languages like C++ or Java. Races involving asynchronous functions and global variables are still a thing, but a lot easier to solve in JavaScript (perhaps a topic for a future article).
  • Dependency injection is an OOP pattern… not. As with most design patterns, it is most commonly used with enterprise OOP. However, the gist of the DI is that the caller provides the dependency rather than the callee obtaining it by itself. While DI has many use cases, we use it here in a very specific way, and this may not be the same way as you’re used to seeing.

Now on to the good stuff!

What’s DI anyway?

DI is a pattern in which the dependency of some code (method or function) is provided at runtime. Because JavaScript is a dynamically typed language, we can skip a whole lot of explanation about DI, and basically boil it down to this:

[…] dependency injection consists of passing parameters to a method.

What do we mean by this? Let’s consider the following code:

export function updateItem(id, getItemData) {
let { title, humanReadableTimestamp } = getItemData(id)
let el = document.querySelector('#item-' + id)
el.querySelector('h3').textContent = title
el.querySelector('p').textContent = humanReadableTimestamp
}

To update an item, we need to retrieve some information based on the item id— we depend on that functionality. During testing, we don’t want to be testing the mechanism for obtaining the information. We just want to test the update process. So, instead of letting the updateItem() find getItemData() (e.g., by importing it from some module), we pass it in as an argument.

test('update the title', () => {
function fakeItemDataGetter() {
return { title: 'Foo', humanReadableTimestamp: 'yesterday' }
}
updateItem(1, fakeItemDataGetter)
expect(getElement('#item-1')).to.exist()
expect(getElement('#item-1 > h3')).to.have.text('Foo')
})

The updateItem() function does not care what the getItemData() function does as long as it returns the title and other necessary information, so we can use this to our advantage to simplify the test code by supplying (injecting) a fake function which does not require storage and has a predictable output.

In a nutshell, this is dependency injection for plain JavaScript that doesn’t use OOP.

Optional variables in JavaScript

The second piece of this method is the optional parameters. In JavaScript, parameters can be given default values. For instance:

function mult(x, y = 1) {
return x * y
}

mult(2, 2) // => 4
mult(2) // => 2

An interesting characteristic of the default values in JavaScript is that they are only evaluated when the function is called, and then only so when the argument for the parameter is omitted or is undefined.

function giveMeDefault() {
console.log('called')
return 1
}

function mult(x, y = giveMeDefault()) {
return x * y
}

mult(2, 2) // => 4 (nothing logged)
mult(2) // => 2 ('called' logged)

Please make a note of two things. Firstly, the default value can reference any value from the scope. Secondly, it can be a function call, and the function call is only performed if the argument (y in this case) is missing.

It gets even more interesting when we look at how parameters interact with each other:

function mult(x, y = x / 2) {
return x * y
}

mult(2, 2) // => 4
mult(4) // => 8

The expression for the default value (x / 2) can reference any parameter to its left. Since the default value can be any expression, it also means that we can invoke functions in the scope:

function giveMeDefault(n) {
console.log('called')
return n / 2
}

function mult(x, y = giveMeDefault(x)) {
return x * y
}

mult(2, 2) // => 4 (nothing logged)
mult(4) // => 8 ('called' logged)

This is the same as doing one of the following:

function mult(x, y) {
if (y === undefined) y = giveMeDefault(x)
return x * y
}

// or

function mult(x, y) {
y ??= giveMeDefault(x)
return x * y
}

Global variables and testing

The one issue with global variables is that it makes functions using them harder to test. Therefore, here I would like to focus on this particular aspect, and how DI comes into play in this scenario.

Let’s consider the following code.


let applicationContext = {
listData: new Map()
}

export function getListItemContext(id) {
let data = applicationContext.listData.get(id)
return {
title: data.title,
timestamp: formatTimestamp(data.updatedAt),
}
}

export function updateListItem(id) {
let { title, timestamp } = getListItemContext(id)
let el = document.querySelector('#item-' + id)
el.querySelector('h3').textContent = title
el.querySelector('p').textContent = timestamp
}

In this case, the getListItemContext() function is a dependency of the of the updateListItem() function, and it is referenced directly by the latter.

The getListItemContext() function uses the global applicationContext variable.

In order to test the updateListItem() function, we would need to mock the getListItemContext() function. Not great, but sure, we can do that. But testing getListItemContext() would require us to mock the applicationContext object. As it is a private bit of state, we cannot even peck at it with our mocking tools.

Just DI it!

Let’s first try to address the need to mock getListItemContext(). To avoid mocking, we can simply inject it so that the test code can provide its own version:

export function updateListItem(id, getContext) {
let { title, timestamp } = getContext(id)
// ...
}

In the test we could do something like this:

test('updates list item title', () => {
let testItem = createTestItem('foo')
updateListItem('foo', () => ({
title: 'My item'
timestamp: new Date(2023, 04, 01).getTime(),
}))
expect(getElement('#item-foo > h3')).to.have.text('My item')
})

OK, DI it… just a little

But now we have a new problem. This changed the function signature for all the callers and made the code possibly more complex in other places.

So how can we make it simpler again (for the caller anyway)? The answer is default parameter values.

export function updateListItem(id, getContext = getListItemContext) {
let { title, timestamp } = getContext(id)
// ...
}

When updateListItem() is invoked without the second parameter, it will use the getListItemContext() function defined in the same scope. So for the existing code, it behaves just like it has before. But during testing, we now have the option of injecting the function. We’ve essentially made DI optional. 💪

In fact, we can make this even simpler:

export function updateListItem(id, {title, timestamp} = getListItemContext(id)) {
// ...
}

This is possible thanks to what we’ve said about how default parameter values work in JavaScript: the second parameter is an object, and destructured to {title, timestamp}. The default parameter value for it is getListItemContext(id) which references a parameter to its left (id), and performs a call to the function in its scope.

Why is this simpler? Now instead of taking a function that evaluates to an object, it takes the object directly. So the test code needs to work a bit less hard.

Being more specific about the parameter has two features that works well for us. Firstly, as I’ve already said, we make testing a bit simpler by not requiring extra “stuff” that surrounds the thing we’re actually interested in. Secondly, it also serves as a documentation about what we want from the environment. And it’s proper code, so it’s not going to get out of whack with reality as time passes. TypeScript people will also notice that this gives them type hints for free as it relies on actual values in the scope (as long as those are appropriately typed).

While we’re at it, we may as well extract the DOM node as a dependency too.

export function updateListItem(
id,
{ title, timestamp } = getListItemContext(id),
el = document.querySelector('#item-' + id),
) {
el.querySelector('h3').textContent = title
el.querySelector('p').textContent = timestamp
}

This allows us to avoid having to mock the DOM tree in order for the test to work:

test('updates list item title', () => {
let testItem = createTestItem('foo')
updateListItem('foo', {
title: 'My item'
timestamp: new Date(2023, 04, 01).getTime(),
}, testItem)
expect(testItem).to.have.text('My item')
})

Injecting global variables

Now we have everything we need to inject the global variable and make the function testable.

let applicationContext = {
listData: new Map()
}

export function getListItemContext(id, context = applicationContext) {
let data = context.listData.get(id)
// ...
}

And just like that, our function is now testable, while still using the global variable in production, and without affecting the usage at the existing call sites.

We can even fine-tune this dependency. For instance, it is not necessary for the function to access the entire application context. It only needs the listData key. It would also simplify the test code if we could just provide a Map object instead of the wrapper object.

let appContext = {
listData: new Map()
}

export function getListItemContext(id, listData = appContext.listData) {
// ...
}

With this little tweak, we have simplified the dependency itself.

But what if the functions are in separate modules?

Suppose that the appContext and the two functions we defined are in separate modules. Maybe the functions are used in multiple places so we needed to extract them. The usage, in that case, may look like this:

// index.js

import * as listItem from './list-item.js'

let appContext = {
listData: new Map()
}

listItem.update(someId)


// list-item.js

let appContext

export function getContext(id, listData = appContext.listData) {
let data = listData.get(id)
return {
title: data.title,
timestamp: formatTimestamp(data.updatedAt),
}
}

export function updateListItem(
id,
{ title, timestamp } = getListItemContext(id),
el = document.querySelector('#item-' + id),
) {
el.querySelector('h3').textContent = title
el.querySelector('p').textContent = timestamp
}

The getListItemContext() no longer has access to the global appContext variable. Well, no problem, since the values are dependency-injected, we can just call the functions with the appropriate arguments:

// index.js

import * as listItem from './list-item.js'

let appContext = {
listData: new Map()
}

listItem.update(someId, listItem.getContext(someId, appContext.listData))

All of a sudden, our call has become convoluted. The whole point of using global variables was to avoid this type of complication to begin with!

Just compare them:

// original
listItem.update(someId)

// updated
listItem.update(someId, listItem.getContext(someId, appContext.listData))

What’s the root cause of this complication? The root cause is not being able to access the global variable. Maybe we can fix that instead of complicating things at the call site.

One such solution is to inject the global variable itself.

// list-item.js

let appContext

export function injectContext(context) {
appContext = context
}

export function getContext(id, listData = appContext.listData) {
// ...
}

We can now go back to the original simple call with a small change:

// index.js

import { updateListItem, injectContext } from './list-item.js'

let appContext = {
listData: new Map()
}

injectContext(appContext)

updateListItem(someId)

Nice and clean again!

Disadvantages

Every solution comes with some caveats. It’s ultimately a balancing game.

The main disadvantage of the optional DI technique as implemented here is that there is technically no distinction between dependency injections and other optional parameters. Therefore, it’s possible to accidentally inject something unintentionally if the reader is not aware of this technique. One also needs to be careful about situations like this:

function updateList(updateItem = updateListItem) {
// ...
}

loadListData().then(updateList)

In the above example, updateList() is going to be called with whatever loadListData() resolves to. If it happens to resolve to a non-undefined value, then updateList() will use that value instead of the updateListItem() dependency. The correct usage of updateList() would be:

loadListData().then(() => updateList())

I had some ideas about how to avoid this. Ultimately it results in more abstraction, and, ironically, it makes the code tightly coupled to those abstractions. I decided that it’s better to just get used to working this way than to end up having more code and harder-to-follow code.

Another less obvious disadvantage is the inability to use this pattern for nested calls. If you have a function that uses optional DI and call it inside another function, there is no way to inject anything into the nested call, making it not testable. I generally avoid doing nested calls and instead have separate functions that compose optional DI functions. These compositions are not tested in unit tests but only in end-to-end tests. This generally works fine, but it can sometimes be inconvenient. Another way to avoid nested calls is to inject the result of calling the dependency rather than the dependency function itself.

Conclusion

The method I’ve just shown you I call “optional DI”. The resulting functions do not require DI, but allow for it when needed. This technique allows us to provide default couplings for our code, so that we do not have to supply it “in production”, but still have the hooks to inject our non-default versions in tests (or otherwise). In JavaScript, this is achieved using the default parameter values thanks to their handy property of being evaluated just-in-time. In short, we can decouple the code as needed, and it remains tightly coupled until we do so.

Using optional DI, we are able to (continue to) use global — well, module-scoped — variables, and we can test such code by swapping the dependency during test.

Optional DI has a different purpose than classic dependency injection. Here DI is merely used as a tool to achieve the stated goal of making code testable while allowing the use of module-scope variables and other in-scope values. Its default usage does not use DI. DI itself can have different applications including such things as giving the code the ability to serve different purposes depending on the code being injected, but that’s not what we were aiming for here.

--

--

Helping build an inclusive and accessible web. Web developer and writer. Sometimes annoying, but mostly just looking to share knowledge.