@eslym/container
v2.1.0
Published
A lightweight dependency injection container for TypeScript and JavaScript.
Readme
@eslym/container
A lightweight, type-safe dependency injection container for TypeScript and JavaScript.
Features
- Type-safe service keys and factory return values
- Lazy, memoized service resolution
- Dependency tracking with automatic invalidation
- Per-container overrides for tests and request-scoped dependencies
- Sync and async resource disposal
- Lifecycle hooks for registration, creation, and resolution
- ESM and CommonJS builds with bundled TypeScript declarations
Installation
npm install @eslym/containerQuick Start
Define the container shape once, then let individual modules extend it through TypeScript declaration merging:
// app.ts
import { ContainerRegistry, type Container } from '@eslym/container';
declare global {
namespace Service {
interface AppContainer {}
type App = Container<AppContainer>;
}
}
export const AppContainer = new ContainerRegistry<Service.AppContainer>('app');Register a database and its configuration in a separate module:
// database.ts
import { Database } from 'bun:sqlite';
import { AppContainer } from './app';
declare global {
namespace Service {
interface AppContainer {
'db.config': {
path: string;
autoMigrate: boolean;
};
db: Database;
}
}
}
export function registerDatabaseService() {
AppContainer.register('db.config', () => ({
path: process.env.DB_PATH ?? './db.sqlite',
autoMigrate: process.env.DB_AUTO_MIGRATE !== 'false'
}));
AppContainer.register('db', function () {
const config = this['db.config'];
const db = new Database(config.path);
if (config.autoMigrate) {
// Perform migrations here.
}
return db;
});
AppContainer.hooks.on('disposed', ({ queueCleanup, values }) => {
if (!values.db) return;
queueCleanup(() => values.db!.close());
});
}Create a container after registering services and access services as properties:
// main.ts
import { AppContainer } from './app';
import { registerDatabaseService } from './database';
registerDatabaseService();
const app = AppContainer.create();
app.db.query('SELECT 1').get();Factories are called only when a service is first accessed. Their results are cached for the lifetime of that container, so repeated access to app.db returns the same instance.
Concepts
Registry
ContainerRegistry<T, Params> stores service factories and creates independent containers from them. The registry name is used in diagnostics and must be provided to the constructor.
const registry = new ContainerRegistry<AppContainer>('app');Register a factory with register. The factory receives the container as this, so it can resolve other services without importing them directly:
registry.register('logger', () => new Logger());
registry.register('users', function () {
return new UserRepository(this.db, this.logger);
});Use registerAll to register several factories at once. Each property is checked against the container's service type:
registry.registerAll({
logger: () => new Logger(),
users: function () {
return new UserRepository(this.db, this.logger);
}
});registerAll skips entries whose factory is undefined or another falsy value. Registering a key that already exists replaces its factory and invalidates the key in existing containers.
Container
Each call to create returns a new isolated container. The optional arguments are passed to every factory in that container:
type Services = {
config: { environment: string };
requestId: string;
};
const registry = new ContainerRegistry<Services, [requestId: string]>('request');
registry.register('config', () => ({ environment: 'test' }));
registry.register('requestId', function (requestId) {
return requestId;
});
const request = registry.create('req-123');
request.requestId; // 'req-123'Overrides and Invalidation
Assigning a service replaces its resolved value and invalidates services that depend on it. This is useful for test doubles and request-specific configuration:
const app = AppContainer.create();
app['db.config'] = {
path: ':memory:',
autoMigrate: true
};
// A subsequently resolved app.db uses the replacement configuration.Deleting a service clears its cached value so the factory will run again on the next access. The proxy supports delete container.value at runtime, but TypeScript reports an error when value is required by the container type. Use the controller API when deleting a required service:
app.$.delete('db');Replacing a factory in the registry also invalidates that service in existing containers. Dependent services are invalidated recursively.
Lifecycle and Disposal
The container does not automatically dispose resolved services. Register cleanup explicitly with the disposed hook and queueCleanup:
AppContainer.hooks.on('disposed', ({ queueCleanup, values }) => {
if (!values.db) return;
queueCleanup(() => values.db!.close());
});Cleanup callbacks can be synchronous or asynchronous. For disposable services, call Symbol.asyncDispose first and fall back to Symbol.dispose when needed:
AppContainer.hooks.on('disposed', ({ queueCleanup, values }) => {
const resource = values.resource;
if (!resource) return;
queueCleanup(() => resource[Symbol.asyncDispose]?.() ?? resource[Symbol.dispose]?.());
});await using still disposes the container, but services are only cleaned up if a disposed hook queues their cleanup:
await using app = AppContainer.create();
// Use app here. Registered disposed hooks run when the scope ends.For manual cleanup, call the container's async disposal method:
const app = AppContainer.create();
try {
app.db;
} finally {
await app[Symbol.asyncDispose]();
}Inspection API
The controller is available through container.$:
app.$.has('db'); // Registered or already resolved
app.$.resolved('db'); // Resolved in this container
app.$.keys(); // Registered and resolved keys
app.$.get('db'); // Equivalent to app.db
app.$.set('db', testDb); // Equivalent to app.db = testDbresolved can also run a callback conditionally:
app.$.resolved('db', (db) => db.close());Hooks
Registries expose register and created hooks. Containers expose resolving, resolved, invalidated, and disposed hooks through container.$:
const registry = new ContainerRegistry<Services>('app');
registry.hooks.on('register', (event) => {
console.log('registered', event.key, event.replace);
});
registry.hooks.on('created', (event) => {
console.log('created with', event.params);
});
const app = registry.create();
app.$.hooks.on('resolved', (event) => {
console.log('resolved', event.key, event.value);
});Container lifecycle events are also forwarded to the registry hooks. A listener registered on registry.hooks receives events from every container created by that registry:
registry.hooks.on('resolved', (event) => {
console.log('resolved in', event.container.$.name, event.key, event.value);
});
registry.hooks.on('disposed', ({ queueCleanup, values }) => {
const resource = values.resource;
if (resource) queueCleanup(() => resource[Symbol.asyncDispose]?.());
});Use container hooks for events from one container, or registry hooks for cross-container observation and cleanup registration. Registry listeners run after listeners registered on the individual container.
hooks.on returns a function that removes the listener. Pass an AbortSignal as the third argument to remove a listener automatically when the signal aborts.
Errors
The package exports these error classes:
KeyNotFoundError: a requested service has not been registeredCircularDependencyError: factories depend on one another in a cycleContainerDisposedError: a disposed container is accessed or modifiedContainerErrorandResolutionError: base classes for container and resolution errors
Ergonomic Type Organization
Keep each service's type augmentation next to its implementation. IDE navigation then leads from app.service to the service module's type and registration code:
// session.ts
declare global {
namespace Service {
interface AppContainer {
session: SessionController;
}
}
}
export function registerSessionService() {
AppContainer.register('session', function () {
return new SessionController(this.db);
});
}Development
This project uses Bun for package scripts and tests.
bun install
bun test
bun run build
bun run lint
bun run formatLicense
MIT. See LICENSE.
