Typesafe, (almost) Zero Cost Dependency Injection in TypeScript | Pure DI in TypeScript Node.js
Vadim Orekhov
Posted on January 9, 2023
Introduction
In this set of articles, I'm going to share my own experience with implementing and using Typesafe Dependency Injection with Registry in TypeScript Node.js backend applications.
So, let's get started with it.
P.S. you can read more about Dependency Inversion vs. Inversion of Control vs. Dependency Injection here and here.
Some Ways of Dependency Injection in TypeScript
The are basically 3 different ways to perform Dependency Injection in TypeScript:
- Higher-order function Arguments Injection
- Class Constructor Injection
- Class Property Injection (will not be covered here since it causes the Temporal Coupling code smell)
Higher-order function
Example:
export function createOrderReceiptGenerator(orderService: OrderService) {
return {
generate(orderId: ID) {
const order = orderService.findById(orderId);
return orderService.generate(order);
},
};
}
Static linking example:
export function main() {
const orderService = createOrderService();
const orderReceiptGenerator = createOrderReceiptGenerator(orderService);
const reciept = await orderReceiptGenerator.generate();
}
Class Constructor Injection
Generally the same idea, but instead of closure, the context is handled by the JavaScript class
feature.
export class OrderReceiptGenerator {
constructor(private readonly orderService: OrderService) {}
public generate(orderId: ID) {
const order = this.orderService.findById(orderId);
return this.orderService.generate(order);
}
}
Static linking example (aka. Pure DI):
export function main() {
const orderService = new DefaultOrderService();
const orderReceiptGenerator = new DefaultOrderReceiptGenerator(orderService);
const reciept = await orderReceiptGenerator.generate();
}
IoC Container
IoC Container - is a service locator where you can register services by a token and later resolve them by the token:
interface Container {
register(token: unknown, config: RegistrationConfig): this;
resolve<T>(token: unknown): T; // throws wnen one of the token in the chain cannot be resolved
}
One of the nice features of IoC containers is that you can define lifetimes for your services.
We usually have the settings for the dependency Lifetime as part of RegistrationConfig
.
Well-known lifetimes:
- Transient - New instance every time
- Scoped - Instance per (HTTP request)/(another invocation)
- Singleton - Instance per application lifetime
With Decorators
This is the most common way to perform Dependency Injection in Typescript.
Unlike Java and C#, when TypeScript is transpiled to JavaScript the type information gets lost, making a TypeScript interface become nothing in JS. Because of this, it's not possible to use the
interface
itself as a token in the IoC Container. Decorators come in handy here to configure the service metadata in the Container.
There are a bunch of libraries that use decorators:
Example of using inversify
decorators:
// order-service.ts
const OrderService = "OrderService";
export interface OrderService {
findOrderById(orderId: ID): Promise<Order>;
generateReceipt(order: Order): Promise<Receipt>;
// ... others
}
// order-receipt-generator.ts
import { inject } from "inversify"; // <-- extra external dependency
export class OrderReceiptGenerator {
constructor(
@inject(OrderService) private readonly orderService: OrderService
) {}
// generate receipt
}
Dynamic linking example:
export function main() {
const container = new Container();
container
.bind<OrderService>(OrderService)
.to(DefaultOrderService)
.inSingletonScope();
container
.bind<OrderReceiptGenerator>(OrderReceiptGenerator)
.toSelf()
.inTransientScope();
// ...later...
const orderReceiptGenerator = container.resolve(OrderReceiptGenerator);
const reciept = await orderReceiptGenerator.generate();
}
Unfortunately, the approach with decorators has some issues:
- The decorator is still an experimental feature in TypeScript and it's not implemented in JavaScript. However, they are on stage 3 now.
- Using the decorators requires importing the reflect-metadata package
- The decorators have performance overhead
- The decorators make constructors more verbose and less readable
- The decorators make a coupling between the business code and the IoC library
- Very hard to override the token in
inject
since the metadata is bonded with the parent class itself
It's worth saying that problems 4-6 have workarounds but they are often not pretty.
Without Decorators
You might also find some libraries that don't depend on decorators, like:
- Pros:
- Typesafe at transpile time
- Requires no extra dependencies on business code
- Cons:
- Requires extra transpile step
- Not typesafe at dev time
- Pros:
- Typesafe at dev time and transpile time
- Requires no extra dependencies on business code
- Cons:
- Requires adding specific
public static inject
construction to class - Works only with Literal Types tokens
- Requires adding specific
Disadvantages
Overall, using the IoC Container is nice and fun, but it also has some disadvantages:
- Usually not typesafe, aka might lead to runtime errors if a desired token is not defined in the container
- Hard to know the dependency lifetime when injecting it
- Injecting short-lifetime dependency into long-lifetime service (for ex. Transient dependency into Singleton service) might lead to unpredicted behavior
- Runtime performance overhead
- IoC Container is Service Locator which is considered to be an anti-pattern when used directly
Should we hold and think a little?
Seems like at this point we have a lot of potential issues with the IoC Container approach. At the same time, the Class Constructor Injection
seemed very clean but the static linking configuration is hard and messy. How can we achieve the configuration simplicity of the IoC Container with dev-time type safety?
Removing Excess
Before we go to the solution, let's talk about the lifetimes.
Previously, I've mentioned 3 lifetimes: Transient, Scoped, and Singleton, but do we really need them given the disadvantages we have using them all?
If we simplify everything to Singleton this might solve most of the problems. So, here is what we can do then:
- Use Factory when Transient dependency is necessary
- Replace Scoped dependency with a scope provider
Use Factory when Transient dependency is necessary
Transient dependencies are tricky. They only work properly when the whole chain of resolution is Transient, otherwise, it might lead to unexpected problems.
So my solution here would be to use a factory when the new instance is required.
Replace Scoped dependency with a scope provider
The one example of using Scoped dependency that comes to my mind, it's HTTP request level caching for libs like dataloader.
This problem seems tough initially, but fortunately, we can actually solve this easily by using Node.js async_hooks feature.
I will provide the solution in the next article.
At this point all our dependencies in the container become Singleton and we can basically replace the container with a single application state object and create it at application startup time (like with did with static linking).
Registry
This approach is inspired by the article and I call it Registry.
Let's start with the example of REST APIs built over fastify:
// orders-routes.ts
export function ordersRoutes(deps: {
orderReceiptGenerator: OrderReceiptGenerator;
}): FastifyPluginCallback {
return (fastify, _, next) => {
fastify.get("/orders/:orderId/receipt", async (request, reply) => {
const { orderId } = request.params as {
orderId: string;
};
const receipt = await deps.orderReceiptGenerator.generateReceipt(orderId);
if (!receipt) {
return reply.status(404).send();
}
return reply.send(receipt);
});
next();
};
}
// create-app-registry.ts
export function createAppRegistry() {
const orderService = new DefaultOrderService();
const orderReceiptGenerator = new DefaultOrderReceiptGenerator(orderService);
// will automatically infer the types and fail at transpile type if services are missing or invalid.
return {
orderService,
orderReceiptGenerator,
};
}
// index.ts
async function main() {
const registry = createAppRegistry();
const server = Fastify({});
server.register(ordersRoutes(registry));
try {
await server.listen({ port: PORT });
console.log("listening on port", PORT);
} catch (err) {
server.log.error(err);
process.exit(1);
}
}
main();
Note that I pass the whole registry
object into ordersRoutes
, while ordersRoutes
itself depends only on a slice of services to avoid unnecessary coupling:
export function ordersRoutes(deps: {
orderReceiptGenerator: OrderReceiptGenerator;
}): FastifyPluginCallback {
...
Here we have the zero-cost type safety, with no runtime overhead.
However, this approach has one issue. The dependency graph might grow very fast and the registry
composing might become painful.
RegistryComposer
To solve the problem we can use the following simple RegistryComposer
class:
export class RegistryComposer<TNeeds extends object = object> {
private readonly creators: CreateServices<TNeeds, object>[] = [];
add<TServices extends object>(
createServices: CreateServices<TNeeds, TServices>
): RegistryComposer<Combine<TNeeds, TServices>> {
this.creators.push(createServices);
return this as any;
}
compose(): Readonly<TNeeds> {
return Object.freeze(
this.creators.reduce((state, createServices) => {
return Object.assign(state, createServices(state));
}, {} as any)
);
}
}
type CreateServices<TNeeds, TServices extends object> = (
needs: TNeeds
) => TServices;
type Combine<TSource extends object, TWith extends object> = Norm<
Omit<TSource, keyof TWith> & TWith
>;
type Norm<T> = T extends object
? {
[P in keyof T]: T[P];
}
: never;
Note: You can find complete source code here.
The RegistryComposer
provides the ability to chain state mutation. Every new call in the chain knows about the previous state modifications.
The CreateServices
function type is a mapper function to create a new Registry state. The needs
argument defines what services the registration depends on, and the function returns new services as a Record.
Note: The
Combine
type is a helper to override field types when a field with the same name added.Note: The
Norm
type is a helper to remove&
(intersections) from resulting TypeScript hints.
Calling compose
finally composes the registry
object:
// create-app-registry.ts
export function createAppRegistry() {
return new RegistryComposer()
.add(() => {
orderService: new DefaultOrderService();
})
.add(
// knowns that the state already contains orderService
({ orderService }) => ({
orderReceiptGenerator: new DefaultOrderReceiptGenerator(orderService),
})
)
.compose();
}
Let's refactor the code to make it prettier:
// create-app-registry.ts
export function createAppRegistry() {
return new RegistryComposer()
.add(orderService())
.add(orderReceiptGenerator())
.compose();
}
function orderService() {
return () => {
const orderService = new DefaultOrderService();
return {
orderService,
};
};
}
function orderReceiptGenerator{
return (needs: { orderService: OrderService }) => {
const orderReceiptGenerator = new DefaultOrderReceiptGenerator(
needs.orderService
);
return {
orderReceiptGenerator,
};
};
}
Looks much better now, we should also move the functions into their files and write tests.
Why almost zero cost?
Obviously, we sacrifice the cold-start performance a little to have the RegistryComposer
and nice-looking add
functions.
Function Injection
Very common the services tend to grow much if not paid attention (not following the Single Responsibility Principle).
// order-service.ts
export interface OrderService {
findOrderById(orderId: ID): Promise<Order>;
addProductToOrder(productId: ID): Promise<void>;
findByProduct(productId: ID): Promise<readonly Order[]>;
findByUser(userId: ID): Promise<readonly Order[]>;
findByCategoryType(categoryType: string): Promise<readonly Order[]>;
generateReceipt(order: Order): Promise<Receipt>;
generateStats(): Promise<OrdersStats>;
// ... others
}
Client code:
// order-receipt-generator.ts
export class OrderReceiptGenerator {
constructor(private readonly orderService: OrderService) {}
// generate receipt
}
To fix the issue, as a first step, we can just break up the OrderService
interface into smaller interfaces.
// order-finder-service.ts
export interface OrderService {
findByProduct(productId: ID): Promise<readonly Order[]>;
findByUser(userId: ID): Promise<readonly Order[]>;
findByCategoryType(categoryType: string): Promise<readonly Order[]>;
}
// order-receipt-service.ts
export interface OrderRecipeService {
generateReceipt(order: Order): Promise<Receipt>;
}
// ... etc
The first doubt comes here with the naming, how should we name the interfaces now properly to emphasize the grouping?
Alternatively, we might also move every method into its own interface like:
// find-orders-by-product-service.ts
export interface FindOrdersByProductService {
findByProduct(productId: ID): Promise<readonly Order[]>;
}
// find-orders-by-user-service.ts
export interface FindOrdersByUserService {
findByUser(userId: ID): Promise<readonly Order[]>;
}
// ... etc
Hmm, seems very verbose now...
What if we could break up the service interface completely to remove the coupling?
Hopefully, unlike languages like C#, TypeScript is functional, meaning we can simply break up the interface to function types, so OrderService
becomes:
Note: In C# we can use
delegate
for it.
// find-order-by-id.ts
export type FindOrderById = (orderId: ID) => Promise<Order>;
// ... others...
// generate-report.ts
export type GenerateReceipt = (order: Order) => Promise<Receipt>;
Looks much cleaner now. Besides, it makes the API simpler, it also makes the API client constructor more understandable:
// order-receipt-generator.ts
export class OrderReceiptGenerator {
constructor(
private readonly findOrderById: FindOrderById,
private readonly generateReceipt: GenerateReceipt
) {}
public generate(orderId: ID) {
const order = this.findOrderById(orderId);
return this.generateReceipt(order);
}
}
Additionally, it solves another problem with understanding of OrderReceiptGenerator
. In the classical (service injection) approach, it's not possible to know what methods of OrderService
are going to be called in OrderReceiptGenerator
without looking into each method code. However, with the functional injection approach, we can just check the OrderReceiptGenerator
constructor dependencies to easily grasp.
Additionally, we can now see if OrderReceiptGenerator
class becomes too complex - when it has too many dependencies.
Adding function to the Registry
To add a function to the Registry we can use the following code snippet
// stub-find-order-by-id.ts
export class StubFindOrderById {
findOrderById: FindOrderById = (id) => {
return Promise.resolve({
id,
});
};
}
// registry/stub-find-order-by-id.ts
export function stubFindOrderById() {
return () => {
const { findOrderById } = new StubFindOrderById();
return {
findOrderById,
};
};
}
We can go even further now, and move Fastify server creation to the RegistryComposer
as well:
// fastify-server.ts
export function fastifyServer() {
return (deps: Parameters<typeof ordersRoutes>[0]) => {
const server = fastify({});
server.register(ordersRoutes(deps));
return {
fastifyServer: server,
};
};
}
// create-app-registry.ts
export function createAppRegistry() {
return new RegistryComposer()
.add(orderService())
.add(orderReceiptGenerator())
.add(fastifyServer())
.compose();
}
// index.ts
async function main() {
const { fastifyServer } = createAppRegistry();
try {
await fastifyServer.listen({ port: PORT });
console.log("listening on port", PORT);
} catch (err) {
fastifyServer.log.error(err);
process.exit(1);
}
}
main();
Conclusion
In this article, we learned how to use Dependency Injection with Registry on TypeScript.
The approach we implemented has the following characteristics:
- typesafe, meaning all the dependencies are resolved at developing/transpile time
- no framework/library dependencies to implement the injection (Pure DI)
- Easy to know the dependency lifetime since all dependencies are "Singleton" like in Registry
- Easy to create multiple instances of the same service with different token
- Easy to chain dependencies with the same token (for ex. for implementing middleware pattern)
- Zero-cost overhead (almost)
In the following articles, I'm going to cover the topics of scoped-like dependencies, logging, configuration, benchmarks, and using the registry within hexagonal/clean architecture projects.
Sample code can be found in the repository.
Posted on January 9, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
January 9, 2023