A lightweight, type-safe dependency injection container for TypeScript and JavaScript.
@eslym/container uses typed context keys to describe values, factories, and dependencies. Values
are resolved lazily, with optional singleton caching, parent-container lookup, batch resolution, and
lifecycle hooks.
npm install @eslym/containerThe package provides both ESM and CommonJS builds, plus bundled TypeScript declarations.
import { Container, context } from '@eslym/container';
const apiUrl = context<string>('apiUrl').key();
const apiClient = context<ApiClient>('apiClient')
.singleton()
.key(({ deps: [url] }) => {
return new ApiClient(url);
}, apiUrl);
class UserService {
constructor(readonly client: ApiClient) {}
}
const userService = context<UserService>('userService')
.singleton()
.key(({ deps: [client] }) => new UserService(client), apiClient);
const container = new Container().set(apiUrl, 'https://example.com');
const service = container.make(userService);The dependency types are inferred from the keys passed to key, so factories receive typed
dependencies without a string-based lookup API.
Create a key with context<T>(name). A key can optionally define a default factory and can be
marked as a singleton:
const transientId = context<string>('transientId').key(() => crypto.randomUUID());
const applicationName = context<string>('applicationName')
.singleton()
.key(() => 'my-app');
const container = new Container();
container.make(transientId); // A new value on each call
container.make(applicationName); // The same value on each callKeys without a default factory must be supplied to a container with set or register.
Use set for an existing value:
const config = context<{ debug: boolean }>('config').key();
const container = new Container().set(config, { debug: true });Use register to provide a factory after creating the key. Dependencies are resolved before the
factory runs:
const host = context<string>('host').key();
const port = context<number>('port').key();
const address = context<string>('address').key();
const container = new Container()
.set(host, 'localhost')
.set(port, 8080)
.register(
address,
({ deps: [currentHost, currentPort] }) => {
return `${currentHost}:${currentPort}`;
},
host,
port
);
container.make(address); // "localhost:8080"createFactory makes a reusable factory explicit, while factoryFromConstructor adapts a class
constructor. Factory callbacks receive the resolving container, typed dependencies, and the factory
instance:
import { Container, context, factoryFromConstructor } from '@eslym/container';
const name = context<string>('name').key();
class User {
constructor(readonly name: string) {}
}
const user = context<User>('user').key(factoryFromConstructor(User, name));
const container = new Container().set(name, 'Ada');
container.make(user); // User { name: 'Ada' }register(key) also accepts a key's default factory when one was provided at key creation time.
Passing a Factory instance preserves that instance; passing a function creates a new factory from
the function and the following dependency keys.
Function keys provide typed invocation through container.call:
const add = context<(left: number, right: number) => number>('add').key();
const loadUser = context<Promise<(id: string) => string>>('loadUser').key();
const container = new Container()
.register(add, () => (left, right) => left + right)
.register(loadUser, () => Promise.resolve((id) => `loaded: ${id}`));
container.call(add, 2, 3); // 5
await container.call(loadUser, 'user-1');Use makeAll to resolve multiple keys while preserving their tuple types. Use makeAllAsync when
the results should be awaited together:
const host = context<string>('host').key(() => 'localhost');
const port = context<number>('port').key(() => 8080);
const container = new Container();
const [currentHost, currentPort] = container.makeAll(host, port);
const [asyncHost, asyncPort] = await container.makeAllAsync(host, port);A container can resolve registrations from a parent container. Child registrations remain local to the child and take precedence over parent registrations for the same key:
const logger = context<Logger>('logger').key();
const parent = new Container().set(logger, new Logger());
const child = new Container(parent);
child.make(logger); // Resolves from parent
child.set(logger, new Logger());
child.make(logger); // Resolves the child's valueUse has to check whether a key is registered in the container or one of its parents:
child.has(logger); // trueUse resolved to check whether a key has a cached value in the container or an ancestor. A
transient factory remains unresolved after make, while singleton values and values supplied with
set are resolved:
const transient = context<number>('transient').key(() => 1);
const singleton = context<number>('singleton')
.singleton()
.key(() => 2);
const container = new Container();
container.resolved(transient); // false
container.make(transient);
container.resolved(transient); // false
container.resolved(singleton); // false
container.make(singleton);
container.resolved(singleton); // trueA child container's local registration takes precedence when checking resolved, even if its parent
has already resolved the same key.
Containers, context keys, and factories expose hooks for lifecycle events. Factory callbacks also
receive the factory instance that is being resolved:
const value = context<number>('value').key(() => 1);
const container = new Container();
container.hooks.on('registered', ({ key }) => {
console.log(`registered ${key.name}`);
});
value.hooks.on('resolving', ({ key }) => {
console.log(`resolving ${key.name}`);
});
value.hooks.on('resolved', ({ value }) => {
console.log(value);
});
value.defaultFactory.hooks.on('registered', ({ key }) => {
console.log(`factory registered for ${key.name}`);
});
const factoryKey = context<number>('factoryKey').key(({ factory }) => {
console.log(factory.dependencies);
return 1;
});registered fires for both register and set on the container, key, and registered factory.
resolving and resolved fire when a value is actually resolved on the container and key. They do
not fire when a cached singleton is returned. A value supplied with set emits these events while it
is being set. The value on a resolved event is mutable, and the final value is returned or cached.
on returns a function that removes the listener and accepts an optional AbortSignal for automatic
removal. Use off to remove a listener directly:
const controller = new AbortController();
const listener = () => {};
const remove = container.hooks.on('registered', listener, controller.signal);
remove();
container.hooks.off('registered', listener);- Factory-backed values are resolved lazily when
makeorcallis used. - Singleton keys cache their resolved value in the container; keys are transient by default.
- A key can only be registered once per container.
- Circular dependencies throw
CircularDependencyError. - Nested
makeandcalloperations are allowed while a dependency graph is resolving. - Missing factories throw an error identifying the key name.
This project uses Bun for package scripts and tests.
bun install
bun test
bun run build
bun run lint
bun run formatMIT. See LICENSE.