Dependency Injection in JavaScript: A Functional Approach

Learn how to apply Dependency Injection in JavaScript using a functional programming style. This approach improves testability, modularity, and developer experience by decoupling dependencies from function logic.

In my first post, I explained how Dependency Injection (DI) enhances developer experience. If you already know what DI is and you are not convinced that it is worth using in JavaScript, I really encourage you to read it first.

In the second post, I discussed why DI is uncommon in the JavaScript ecosystem and shown a possible implementation in TypeScript using an object-oriented approach.

This post builds on the second one and focuses on applying DI in JavaScript using a functional programming style.

Javascript Without DI

For simplicity, I’ll reuse the example from my previous post. Let’s consider a service that reads a configuration file and returns a server URL.

Here’s a basic TypeScript implementation without DI:

// server-url-provider.ts
import fs from 'fs';

export function getServerURL(fileName: string): string {
  const json = JSON.parse(fs.readFileSync(fileName, 'utf-8'));
  return json.url;
}
// app.ts
import { getServerURL } from './server-url-provider';
const serverURL = getServerURL('config.json');

This looks simple, but it has issues:

  1. How can you test getServerURL without creating a physical file to read?
  2. What if you need to include the currently logged-in user in the getServerURL function to provide a different path based on the user?

Possible solutions to problem #1

Over time, JavaScript has developed various patching techniques to test some "hard-to-test" code using mocking libraries and relying on global state. Most of them wouldn't be needed if Dependency Injection was more common in JavaScript.

We will see a complete example in a moment.

Possible solutions to problem #2

To provide additional dependencies, you might try to add a second argument to the getServerURL function to pass the user information:

import fs from 'fs';

export function getServerURL(fileName: string, userId: string): string {
  const json = JSON.parse(fs.readFileSync(`users/${userId} /` + fileName , 'utf-8'));
  return json.url;
}

This approach has one big drawback: getServerURL might be used in dozens of places, and you would need to update all of them to pass the user information.

Alternatively, you might try to use a global state to get the user information, like this:

import fs from 'fs';
import session from 'app/session';

export function getServerURL(fileName: string): string {
  const json = JSON.parse(fs.readFileSync(`users/${session.getUserId()} /` + fileName , 'utf-8'));
  return json.url;
}

While this approach seems better (and in practice is the most common), it introduces a global-state/hidden dependency (session), making the code harder to test and reason about.

Another issue with this approach: what happens if there is no session because the code is running a background job? The getServerURL function becomes tightly coupled to the session management, making it less reusable in contexts where sessions are absent.

Testing without Dependency Injection

A test for the getServerURL function without DI can look like this:

import fs from 'fs';
import session from 'app/session';
import { getServerURL } from './server-url-provider';

jest.mock('fs');
jest.mock('app/session');

(session.getUserId as jest.Mock).mockReturnValue(42);
(fs.readFileSync as jest.Mock).mockReturnValue('{"url": "https://example.com"}');

const url = getServerURL('config.json');

expect(session.getUserId).toHaveBeenCalled();
expect(fs.readFileSync).toHaveBeenCalledWith('users/42/config.json', 'utf-8');
expect(url).toBe('https://example.com');

You can see that:

  • We are mocking the fs import to be able to mock the readFileSync function.
  • We are mocking the app/session import to be able to mock the session.getUserId function.
  • We are mocking the return value of readFileSync to return a predefined JSON string.
  • We are mocking the return value of session.getUserId to return a predefined user ID.
  • The test is supposed to test the getServerURL function, but it also tests the way we interact with the file system (the fact that fs.readFileSync requires a utf-8 argument is totally irrelevant to the getServerURL function logic; it is much more related to the filesystem API).

This approach relies heavily on Jest's ability to mock module imports and global functions. It seems fine but gets messy quickly, especially if the function to test requires different dependencies. Without noticing it, most of your test code will be Jest's mocking magic.

Dependency Injection to the rescue

A better version of the above code, using Dependency Injection, could look like this:

// server-url-provider.ts (a service that reads a configuration file and returns a server URL)

export type Reader = (fileName: string) => string;
export type ServerUrlProvider = (fileName: string) => string;

export const createGetServerUrl = (fileReader: Reader): ServerUrlProvider => {
  return (fileName: string): string => {
    const json = JSON.parse(fileReader(fileName));
    return json.url;
  };
}

The key part here is that createGetServerUrl takes as an argument the dependencies the inner function needs to operate on, while the inner function has as arguments what it needs to operate on.

By decoupling the dependencies from the function itself, we can:

  1. Easily test the function by passing a mock implementation of the dependencies.
  2. Add additional dependencies without changing the function signature or hunting down all the places where the function is used.

This technique (where you create a new function with some of the dependencies already applied) in functional programming is often referred to as "partial application" or "function currying".

Notice also how the createGetServerUrl function does not depend on any global state, nor does it depend on the file system. It requires only a Reader function that reads a file and returns its content as a string.

Completing the example

We can proceed and implement the Reader function that reads a file.

// file-reader.ts (a simple file reader implementation)
import Reader from './server-url-provider';
import fs from 'fs';

export const fileReader: Reader = (fileName: string): string => {
  return fs.readFileSync(fileName, 'utf-8');
}

Now that we have a function that reads a file, we can create the getServerURL function using the createGetServerUrl function:

// services.ts
import { createGetServerUrl } from './server-url-provider';
import { fileReader } from './file-reader';

export const getServerURL = createGetServerUrl(fileReader);

And lastly, we can use the getServerURL function in our application:

// app.ts (the main entry point of the application)
import { getServerURL } from "./services";
const serverURL = getServerURL('config.json');

This is a much better approach, we have:

  • Decoupled the dependencies from the function itself. The function creation is now separated from its invocation.
  • serverURL takes only one parameter, the file name. If additional dependencies are needed, app.ts does not need to be updated. We do not need to hunt down all the places where getServerURL is used, and we can easily add new dependencies to the createGetServerUrl function since it will have to be defined exactly once in the servcies.ts file.

Adding the currently logged-in user

Implementing the getServerURL function to include the currently logged-in user is easy.

// server-url-provider.ts 

export type Reader = (fileName: string) => string;
export type ServerUrlProvider = (fileName: string) => string;
export type CurrentUserProvider = () => number;

export const createGetServerUrl = (fileReader: Reader, currentUser: CurrentUserProvider): ServerUrlProvider => {
  return (fileName: string): string => {
    const json = JSON.parse(fileReader(`users/${currentUser()}/` + fileName));
    return json.url;
  };
}

We have defined a new type CurrentUserProvider, which is a function that returns the currently logged-in user's ID. For simplicity, we will assume it returns a user ID as a number stored in the session. Each framework has its own way of managing sessions, so I won't go into details here.

// current-user.ts (a service that provides the currently logged-in user)
export const createCurrentUserProvider = (session: Session): CurrentUserProvider => {
  return (): number => {
      // Logic to get the currently logged-in user
  };
}
// services.ts
import { createGetServerUrl, ServerUrlProvider } from './server-url-provider';
import { fileReader } from './file-reader';
import { createCurrentUserProvider } from './current-user';

export const session = retrieveSession(); // Assume this function retrieves the session object from your framework
export const currentUserProvider = createCurrentUserProvider(session);
export const getServerURL = createGetServerUrl(fileReader, currentUserProvider);

We could stop here; our implementation is complete. The services.ts is the central place where we define our dependencies and all services/functions available in our application. The "domain-specific" code is simple and testable without special mocking libraries.

Testing with Dependency Injection

To test the getServerURL function, since now it is decoupled from the file system and global state, we can create a mock implementation of dependencies without the need for Jest.

import { createGetServerUrl } from './server-url-provider';

const mockReader = (path) => {
   expect(path).toBe('users/42/config.json');
   return '{"url": "https://example.com/api"}';
};
const mockUserProvider = () => 42;

const getServerURL = createGetServerUrl(mockReader, mockUserProvider);

const url = getServerURL('config.json');
expect(url).toBe("https://example.com/api");

If you compare this test with the previous one, you can see that it does not rely on any global state or mocking libraries. This test is sharply focused on the getServerURL function logic, and does not test the way we interact with the file system or the session management.

Adding a Dependency Injection Container

The main problem with the services.ts above is that it forces you to know and resolve all the dependencies needed to create a particular service.

In our specific case, in order to call createGetServerUrl:

  • We need to call createCurrentUserProvider.
  • In order to call createCurrentUserProvider, we need to call retrieveSession.
  • In order to call retrieveSession, we need to know how the session is managed in our framework.

In a complex application, this can become cumbersome and might require skills that not all the developers working on the project have. On top of that, in more complex cases when the services.ts file is being interpreted, the session might not be available yet. That would force us to implement complex logic to delay the session function creation until the session is available (as an example, returning a function that wraps the session function and does not try to resolve the session until it is invoked for the first time).

Despite the fact that there are solutions to some of the problems above, they tend to introduce complexity and make the code harder to maintain. A better approach would be to use a Dependency Injection (DI) container.

In this example, I will use microsoft/tsyringe, but InversifyJS is also a great choice.

A dependency injection container works in two steps:

  1. Service Registration: You register your services and "tell" the container which dependencies each service needs. It is important to note that the dependencies are not resolved at this point. They are just a flat list of declarations.
  2. Service Resolution: You ask the container to resolve a service. It will provide you with an instance of that service, taking care of resolving all its dependencies recursively.

A possible registration of the services in the DI container could look like this:

// services.ts (Dependency Injection Container definition)
import { container } from "tsyringe";

import { createGetServerUrl, ServerUrlProvider, Reader, CurrentUserProvider } from './server-url-provider';
import { fileReader } from './file-reader';
import { createCurrentUserProvider } from './current-user';

container.register<Reader>("Reader", { useValue: fileReader });

container.register<ServerUrlProvider>("ServerUrlProvider", {
    useFactory: (c) => createGetServerUrl(
        c.resolve<Reader>("Reader"), 
        c.resolve<Reader>("CurrentUserProvider")
    )
});

container.register<CurrentUserProvider>("CurrentUserProvider", {
   // assuming Session is already registered in the container via framework-specific dependency
   useFactory: (c) => createCurrentUserProvider(c.resolve<Session>("Session"))
});

export { container };

Notice how in services.ts, we are defining the ServerUrlProvider without worrying about the fact that its CurrentUserProvider dependency is declared later in the file. This is because at this point we are only registering the services and not resolving them.

Now in our application, we can use the DI container:

// app.ts
import { container } from "./services";

const getServerURL = container.resolve<ServerUrlProvider>("ServerUrlProvider");
const serverURL = getServerURL('config.json');

A DI container allows you to scale your application to thousands of services without worrying about how to obtain the dependencies needed for each of those services. As long as all the needed dependencies are registered in the DI container, they will be resolved automatically when you request a service.

Conclusion

Now that we have used Dependency Injection in our JavaScript application, we can see the benefits:

  1. Testing getServerURL is straightforward. You can call createGetServerUrl with a mock implementation of Reader that returns a predefined JSON string.
  2. Adding additional dependencies, such as the currently logged-in user, is now straightforward. The only place that needs to be updated is the service creation in services.ts. That's it! Now the getServerURL function can access the currently logged-in user without needing to change its signature or hunt down all the places where it is used. The initial app.ts file remains unchanged.

Are there any drawbacks to this approach? The only "real" drawback I see is that it requires a bit more boilerplate code to set up the DI container and register the services. This is mostly because JavaScript DI container implementations are not yet mature like some other languages (e.g., Java or PHP) where the services.ts file is not needed at all, it is created automatically by the framework and the developers do not even need to know it exists.

Some developers are also obsessed with type safety and might argue that dependencies provided by the DI container are not type-safe. That is certainly true, but i think that is a small price to pay for the benefits of using DI.

Looking Ahead

Embracing Dependency Injection in your JavaScript projects leads to cleaner, more modular, and easily testable code. By decoupling dependencies from your functions, you gain flexibility and make your applications easier to maintain and scale. Whether you use a DI framework or simple function composition, adopting this pattern will improve your developer experience and the overall quality of your codebase.

Using a DI framework like microsoft/tsyringe or InversifyJS is not mandatory, but it certainly helps. What is important is to decouple the function creation from its invocation.

Give it a try in your next project! (please!)

design-patterns, dependency-injection, software-development, testing, developer-experience, javascript, typescript

Want more info?