@monkin/di
v0.4.1
Published
Small type-safe dependency injection lib
Maintainers
Readme
@monkin/di
@monkin/di is a lightweight (560 bytes), type-safe dependency injection container for TypeScript. It leverages TypeScript's advanced type system to provide a fluent API for service registration and resolution with full type safety and autocompletion.
Table of Contents
Features
- Full Type Safety: Get autocompletion and type checks for all your injected services.
- No Decorators: No need for
reflect-metadataor experimental decorators. Pure TypeScript. - Fluent API: Chainable service registration makes it easy to compose your container.
- Lazy: Services are instantiated only on demand (when first accessed) and reused for subsequent accesses.
- Disposable: A container holding a
Disposableservice isDisposable, and one holding anAsyncDisposableservice isAsyncDisposable.usingorawait usingtears down every service that was actually created, in reverse registration order. - Child Containers: A container can be created from a parent. It exposes the parent's services, and disposing it leaves the parent alone.
- Zero Runtime Dependencies: Extremely lightweight (560 bytes minified / 384 bytes gzipped).
Installation
npm install @monkin/diUsage
1. Defining a Service
A service is a class that implements the DiService interface. It must implement a getServiceName() method which will be used as the key in the container. Use as const to ensure the name is treated as a literal type. The method is called on the class prototype, before any instance exists, so it must not use this or instance fields.
import { DiService } from '@monkin/di';
export class LoggerService implements DiService<"logger"> {
getServiceName() {
return "logger" as const;
}
log(message: string) {
console.log(`[LOG]: ${message}`);
}
}2. Basic Injection
Use DiContainer to register and resolve your services. You can register a single service or multiple services in one call. When registering multiple services, the order doesn't matter; they can even depend on each other.
import { DiContainer } from '@monkin/di';
import { LoggerService } from './LoggerService';
import { ConfigService } from './ConfigService';
// Single service
const container = new DiContainer()
.inject(LoggerService);
// Multiple services in one call (order-independent)
const multiContainer = new DiContainer()
.inject(ConfigService, LoggerService);
// Access the service directly on the container
container.logger.log("Service is ready!");3. Services with Dependencies
To inject dependencies into a service, define its constructor to accept the container. You can use the Di<...T> type helper to specify which services are required. Multiple services are passed as separate type arguments.
import { Di, DiService } from '@monkin/di';
import { LoggerService } from './LoggerService';
import { ConfigService } from './ConfigService';
export class UserService implements DiService<"user"> {
getServiceName() {
return "user" as const;
}
// Single dependency:
// constructor(private di: Di<LoggerService>) {}
// Multiple dependencies:
constructor(private di: Di<LoggerService, ConfigService>) {}
getUser(id: string) {
const prefix = this.di.config.get("userPrefix");
this.di.logger.log(`Fetching user: ${prefix}${id}`);
return { id, name: "User " + id };
}
}
const container = new DiContainer()
.inject(LoggerService)
.inject(ConfigService)
.inject(UserService);
container.user.getUser("42");When using inject with multiple services, they can depend on each other regardless of the order they are passed to the method. Order only matters for teardown: registering dependencies first, as above, means they outlive the services that use them. See Disposing Services.
4. Lazy
Services registered via inject are always lazy. When you register a service, @monkin/di creates a Proxy for it on the container. The actual service instance is only created when you first interact with it (e.g., call a method, access a property). Once created, the same instance is reused for all subsequent accesses.
const container = new DiContainer()
.inject(ExpensiveService);
// ExpensiveService is NOT instantiated yet
const service = container.expensive;
// Still NOT instantiated! `service` is a Proxy.
console.log("Container ready");
// ExpensiveService is instantiated NOW because we access a property/method
service.doSomething();Laziness does not affect teardown: the disposal order is fixed by the order services are registered in, not by when they are instantiated — see below.
5. Disposing Services
A container becomes Disposable as soon as one of its services implements Symbol.dispose, so it works with the using declaration. Disposing a container calls [Symbol.dispose]() on every service that implements it. A container with no disposable services has nothing to dispose, so its type is not Disposable at all.
class ConnectionService implements DiService<"connection"> {
getServiceName() { return "connection" as const; }
[Symbol.dispose]() {
this.close();
}
}
{
using container = new DiContainer()
.inject(ConfigService)
.inject(ConnectionService)
.inject(RepositoryService);
container.repository.findAll();
} // Disposed here: repository, then connection (config has nothing to dispose)Or dispose explicitly:
container[Symbol.dispose]();Async disposal
Once any service implements Symbol.asyncDispose, the container becomes AsyncDisposable instead, for the await using declaration. Async disposal awaits each service before moving on to the next, calling [Symbol.asyncDispose]() where available and [Symbol.dispose]() otherwise.
class DatabaseService implements DiService<"database"> {
getServiceName() { return "database" as const; }
async [Symbol.asyncDispose]() {
await this.pool.end();
}
}
{
await using container = new DiContainer()
.inject(ConnectionService) // Disposable
.inject(DatabaseService); // AsyncDisposable
container.database.query();
} // Awaits database, then disposes connectionAsyncDisposable takes priority. Whatever else is injected before or after, an AsyncDisposable container is not Disposable at the type level, so a plain using on it is a compile error rather than a teardown that is silently never awaited.
Services are disposed in the reverse of the order they were registered, whatever order they happened to be instantiated in. Register dependencies before the services that use them — the natural, foundations-first order — and teardown runs in the right direction on its own: every service is disposed before the things it depends on, so it can still use them in its own [Symbol.dispose]().
Details worth knowing:
- Order is static: it comes from the
injectcalls, so it is the same on every run and does not shift when a code path happens to touch a service earlier or later. - One continuous sequence: arguments count left to right and calls count in order, so
inject(A, B).inject(C, D)disposesD,C,B,A. Splitting services acrossinjectcalls never changes the result. - Lazy-friendly: services that were never used are never created, so they are never disposed. Reading
[Symbol.dispose]or[Symbol.asyncDispose]off an unused service returns a no-op rather than constructing it just to tear it down. - Created while disposing: a service instantiated for the first time inside another service's
disposeis still disposed in this teardown, as long as it was registered earlier than the service that created it. - Do not reach forward: a service registered after the one being disposed has already been torn down if it was used, and is silently created now, never to be disposed, if it was not. Either way it is the wrong direction, so register dependencies first.
- Optional: services with neither a
[Symbol.dispose]()nor a[Symbol.asyncDispose]()method are skipped. - Idempotent: the registry is drained as it is disposed, so disposing the container again does nothing.
[!NOTE] Disposal requires TypeScript 5.2+ with a
libthat includes the disposable types, e.g."lib": ["ES2020", "ESNext.Disposable"](otherwise the shipped.d.tsfails to compile unlessskipLibCheckis on). At runtime it requires nativeSymbol.disposeandSymbol.asyncDispose, available from Node 18.18 and 20.5. The rest of the library still works on older Node 18 releases; only disposal does not.
6. Duplicate Service Name Protection
@monkin/di prevents registering multiple services with the same name. This protection works at both compile-time and runtime:
- Type-level Check: If you
injecta service with a name that already exists in the container, the call resolves to a string literal describing the error instead of a container. Anything you do with the result, such as chaining anotherinjector accessing a service, is a compile error. The call itself is rejected too when the service takes aDi<...>constructor argument. - Runtime Check: The
injectmethod throws anErrorif a duplicate name is detected.
const container = new DiContainer()
.inject(LoggerService);
// Runtime Error: Duplicated service name: logger
const broken = container.inject(AnotherLoggerService);
// Type of `broken`: "Duplicate service name: logger"
// TypeScript Error: Property 'logger' does not exist on type '"Duplicate service name: logger"'
broken.logger;7. Reserved Field Names
Since DiContainer uses a fluent API, certain names are reserved for its internal methods and cannot be used as service names:
inject_— the internal registry of registered services to dispose
Similar to duplicate names, attempting to use a reserved name triggers both a Type-level Check and a Runtime Check. Names of Object.prototype members, such as constructor, toString or hasOwnProperty, are rejected at runtime as well, but not at the type level.
class InjectService implements DiService<"inject"> {
getServiceName() { return "inject" as const; }
}
// Runtime Error: Reserved service name: inject
const broken = new DiContainer().inject(InjectService);
// Type of `broken`: "Reserved field name: inject"
// TypeScript Error: Property 'inject' does not exist on type '"Reserved field name: inject"'
broken.inject(LoggerService);8. Circular Dependencies
@monkin/di supports circular dependencies between services because it uses Proxies for lazy initialization. A service can depend on another service that depends back on it, provided that they don't try to access each other's methods or properties in their constructors.
class ServiceA implements DiService<"a"> {
getServiceName() { return "a" as const; }
constructor(private di: Di<ServiceB>) {}
doA() {
console.log("A doing something...");
this.di.b.doB();
}
}
class ServiceB implements DiService<"b"> {
getServiceName() { return "b" as const; }
constructor(private di: Di<ServiceA>) {}
doB() {
console.log("B doing something...");
}
}
const container = new DiContainer().inject(ServiceA, ServiceB);
container.a.doA(); // Works fine![!IMPORTANT] Do not access circular dependencies in the constructor, as this will trigger a stack overflow during instantiation.
9. Child Containers
Pass a container to the DiContainer constructor to create a child of it. The child exposes every service of the parent, and services injected into the child can depend on them. This is the natural fit for scopes: application-wide services in a parent, and a short-lived child per request, job, or test.
const app = new DiContainer()
.inject(ConfigService)
.inject(DatabaseService);
class SessionService implements DiService<"session"> {
getServiceName() { return "session" as const; }
constructor(private di: Di<DatabaseService>) {}
[Symbol.dispose]() { /* release the session */ }
}
{
using request = new DiContainer(app).inject(SessionService);
request.session; // Own service
request.config; // Parent service
} // Disposes session only; the parent is untouchedA parent service accessed through a child is created lazily as usual and shared with the parent: there is one instance, it lives on the parent, and the parent disposes it.
- One namespace: a child cannot register a name the parent already uses. It is reported as a duplicate, both at the type level and at runtime, just like a duplicate within a single container.
- Own services only: a child is
DisposableorAsyncDisposablebased on its own services, not the parent's. A child that registered nothing has nothing to dispose and is notDisposableat all, and a child of anAsyncDisposableparent with onlyDisposableservices of its own is plainDisposable. - Dispose children first: a parent does not know about its children. Disposing the parent tears down the parent's services while the children keep running, so dispose every child before its parent.
- Nesting: a child can itself be a parent. A grandchild sees the services of every ancestor.
- Register before branching: the child's type is fixed when it is created. Services injected into the parent later are reachable at runtime but not in the child's type.
API Reference
DiContainer
The main class for managing services. Its type, DiContainer<Services, Inherited>, exposes every registered service by name and is AsyncDisposable if any own service is, otherwise Disposable if any own service is. Inherited is the union of the service names that belong to a parent container; it defaults to never.
new DiContainer(parent?: DiContainer)Creates a container. With aparent, the new container exposes the parent's services and can register its own on top; see Child Containers.inject(...ServiceClasses: new (di: any) => any): DiContainerRegisters one or more service classes. Returns the container instance, typed with the newly added services. Each service can depend on other services provided in the same call or already present in the container. For a duplicate or reserved name the return type is the error message as a string literal instead of a container; see Duplicate Service Name Protection.[Symbol.dispose](): voidPresent when an own service isDisposableand none isAsyncDisposable. Disposes every instantiated own service in reverse registration order. Services that were never used are not instantiated, services without a[Symbol.dispose]()method are skipped, and the parent's services are not touched.[Symbol.asyncDispose](): Promise<void>Present when any own service isAsyncDisposable. Same order and laziness, awaiting each service in turn. Calls[Symbol.asyncDispose]()where available and[Symbol.dispose]()otherwise.
DiService<Name>
An interface that your service classes must implement.
getServiceName(this: null): NameMust return the unique name of the service as a string literal type. It is called on the prototype without an instance, so it cannot usethis.
Di<...S>
A utility type to help define dependencies in your service constructors.
Di<ServiceClass>: Resolves to an object with the service name as the key and the service instance as the value.Di<Service1, Service2, ...>: Resolves to a merged object containing all specified services, up to 16 of them.
Services must be passed as separate type arguments. Tuple syntax is not supported:
// Supported
constructor(private di: Di<ServiceA, ServiceB>) {}
// NOT supported - resolves to `never`, not to the merged service object
constructor(private di: Di<[ServiceA, ServiceB]>) {}Development
Installation
npm installBuild
npm run buildTest
npm test
npm run test:watch # Watch modeLinting & Formatting
npm run lint # Run Biome check (lint, format, and import sorting)
npm run format # Format code with Biome
npm run format:check # Check code formatting with BiomeLicense
MIT
