Skip to main content

How we use dependency injection with TypeScript at Orus

ยท 6 min read
Marvin Roger
Marvin Roger
Senior Software Engineer
Cover

Curiously, dependency injection is not something that we often see in the JavaScript ecosystem.

Still, it's a powerful pattern that can help you make your code more flexible and easier to test, if implemented properly.

After a lot of iterations, we've come up with a dependency injection system that works well for us at Orus. The point of this article is not to cover exactly how dependency injection works, but rather to share our learnings in building it.

Objectivesโ€‹

  1. Be completely type-safe. A token registered for a given type should make it impossible to use the wrong type when being injected
  2. The container should only be used inside entrypoints. Everything else should be injectable
  3. The syntax should be as simple as possible
  4. Only use native TypeScript features. No code generation, experimental decorators, etc.
  5. It should be compatible with bundling

Don't reinvent the wheelโ€‹

Building a robust DI container is not exactly trivial. After a careful review of the existing DI libraries, we ended up choosing Awilix by Jeff Hansen (@jeffijoe) (thanks, by the way! ๐Ÿ™) because it ticks all the boxes, it's lightweight, simple and well-maintained.

In its simplest form, Awilix works this way:

import { createContainer, asFunction, asClass } from 'awilix'

// We define the types of our dependencies (e.g. abstractions)

type EmailSender = {
sendEmail: (email: string) => Promise<void>
}

type Logger = {
info: (message: string) => void
warn: (message: string) => void
}

// We define the list of dependencies along with their types (it's named "cradle" in Awilix)

type Cradle = {
emailSender: EmailSender
logger: Logger
}

const container = createContainer<Cradle>()

// We register every token with its implementation (e.g. concretions)

container.register({
emailSender: asFunction(buildSendgridEmailSender).singleton(),
logger: asClass(ConsoleLogger).singleton(),
})

buildSendgridEmailSender would look like this:

function buildSendgridEmailSender(deps: { logger: Logger }) {
return {
sendEmail: async (email: string) => {
logger.info(`Sending email to ${email}`)
}
}
}

Not too bad, but there are two issues here. First, it's a bit verbose because we have to define the type of our dependencies, with the name of the token and their type.

But the main issue is that nothing prevents us from writing the wrong type. For example, we could refer to the ConsoleLoggerFactory concretion type instead of the LoggerFactory abstraction. While it would work, it would not be correct and would prevent us from testing the code properly, because mocks would be harder to write.

Simplifying dependency declarationโ€‹

What if, instead of having to define the type of our dependencies, we just used the Cradle type?

function buildSendgridEmailSender(deps: Cradle)

It would technically work, but it would defeat the purpose of dependency injection, as it now looks like the function depends on absolutely everything, when it actually requires only a few dependencies. Testing would not be practical. Alright, let's Pick then?

function buildSendgridEmailSender(deps: Pick<Cradle, 'logger'>)

Better! We're now making it clear that our function requires only the logger. However, there's still a subtle issue that you, fellow reader, might have found out already: importing the Cradle from each dependency would create a circular dependency. If the Cradle contains A and B in their respective modules, and A and B import the cradle module, you'll have a nice cycle.

While it works in TypeScript due to the way the type system works, a circular dependency between modules is something that should be avoided. We might have performance issues or obscure bugs that are hard to debug.

To avoid this, we ended up exporting a utility type in the global scope from the cradle module:

declare global {
type Dependencies<Name extends keyof Cradle> = Pick<Cradle, Name>
}

Now, we can just use the Dependencies type to refer to the dependencies of a given module:

function buildSendgridEmailSender(deps: Dependencies<'logger'>)

In practice, this is cool to use:

  • You don't have to import anything from the module, Dependencies is available globally
  • You get autocompletion when using the Dependencies type
  • It's impossible to use the wrong type, because the type is already bound and you don't have to write it explicitly

info

Like what you read? We're based in Paris and we're hiring Software Engineers! Check out our ๐ŸŒŸ job offer!


Usageโ€‹

Armed with this, we can now use our container in our entrypoints. Let's imagine we have a simple script that sends an email:

import { container } from './container'

const emailSender = container.resolve('emailSender')
await emailSender.sendEmail('hello@orus.local')

Under the hood, Awilix instantiates the logger, then the email sender.

How does it work?

Awilix uses a clever trick, but a simple one: it passes a Proxy as the first argument of the injectable function (or class constructor).

This proxy then intercepts what dependency is being accessed, and returns the correct one from the container, instantiating other dependencies as needed.

Bonus: unit testing with mocksโ€‹

One of the main benefits of dependency injection is the ability to easily swap implementations for tests, without having to use monkey patching, which can be tricky (especially with ES modules).

Let's imagine we want to test the buildSendgridEmailSender function:

import { vi, it, expect } from 'vitest'

it('should send an email', async () => {
const loggerMock = {
info: vi.fn(),
warn: vi.fn(),
}

const emailSender = buildSendgridEmailSender({ logger: loggerMock })

await emailSender.sendEmail('hello@orus.local')

expect(loggerMock.info).toHaveBeenCalledWith('Sending email to hello@orus.local')
})

Notice how clean the test is. No type assertions, no monkey patching, the buildSendgridEmailSender function screams what it needs to be injected. If the email sender requires one more dependency later, TypeScript will report a clear error and we won't have to hunt for the issue (like we would if we used monkey patching).

There's one small improvement we can do here, though: we mock warn even though it's not used in the function. The solution for this is to Pick the properties we need from the logger, through a PickDependencies utility type:

function buildSendgridEmailSender(deps: PickDependencies<{
logger: 'info'
}>)

To make it more clear, PickDependencies<{ logger: 'info' }> is equivalent to { logger: Pick<Logger, 'info'> }. It allows us to pick the properties we need for each dependency we're interested in, without having to import the Cradle type, while still being type-safe and benefit from autocompletion.

This utility type is left as an exercise to the reader, but it's a good way to exercise your TypeScript generics skills. ๐Ÿ˜‰


Here we go, we've covered how we use dependency injection at Orus.

If you have any questions, feedback or suggestions, or want to join us in the Orus adventure, don't hesitate to reach out to me on LinkedIn, and I'll gladly reply!

Last, but not least...

Bye