Skip to content

Repository files navigation

@eslym/container

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.

Installation

npm install @eslym/container

The package provides both ESM and CommonJS builds, plus bundled TypeScript declarations.

Quick Start

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.

Context Keys

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 call

Keys without a default factory must be supplied to a container with set or register.

Registering Values And Factories

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

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');

Batch Resolution

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);

Parent Containers

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 value

Use has to check whether a key is registered in the container or one of its parents:

child.has(logger); // true

Use 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); // true

A child container's local registration takes precedence when checking resolved, even if its parent has already resolved the same key.

Hooks

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);

Resolution Rules

  • Factory-backed values are resolved lazily when make or call is 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 make and call operations are allowed while a dependency graph is resolving.
  • Missing factories throw an error identifying the key name.

Development

This project uses Bun for package scripts and tests.

bun install
bun test
bun run build
bun run lint
bun run format

License

MIT. See LICENSE.

About

A lightweight, type-safe dependency injection container for TypeScript and JavaScript.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages