@ironbase/di
v1.0.0
Published
A small, type-safe dependency container for TypeScript
Readme
@ironbase/di
A small, type-safe dependency container for TypeScript with singleton, scoped, and transient lifetimes.
@ironbase/di provides explicit dependency management without decorators,
reflection metadata, constructor inspection, or a global container. Its core API
is set, get, and createScope.
Installation
bun add @ironbase/dinpm install @ironbase/diQuick Start
Declare a service map, create a container, and register typed factories:
import { createContainer } from "@ironbase/di";
class Logger {
info(message: string) {
console.log(message);
}
}
class Database {
constructor(private readonly logger: Logger) {}
async [Symbol.asyncDispose]() {
this.logger.info("Database connection closed");
}
}
class UserRepository {
constructor(private readonly database: Database) {}
}
interface Services {
logger: Logger;
database: Database;
users: UserRepository;
}
const container = createContainer<Services>();
container
.set("logger", "singleton", () => new Logger())
.set("database", "scoped", ({ get }) => new Database(get("logger")))
.set("users", "transient", ({ get }) => new UserRepository(get("database")));
await using scope = container.createScope();
const users = scope.get("users"); // UserRepositoryThe service map connects each key to its value type. TypeScript rejects unknown keys and factories that return the wrong type.
Examples
The examples directory contains independently runnable examples:
basic.tsdemonstrates typed registration and resolution.scopes.tsisolates scoped values across concurrent requests.disposal.tsdemonstrates native reverse-order cleanup.errors.tsprints scope and circular dependency diagnostics.circular-dependencies.tsdetects a cycle and demonstrates how to refactor it into an acyclic graph.
Run an example with Bun:
bun examples/scopes.tsLifetimes
| Lifetime | Creation | Reuse | Owner |
| --- | --- | --- | --- |
| singleton | On first resolution | Every resolution | Root container |
| scoped | On first resolution within a scope | Within that scope | Child scope |
| transient | On every resolution | Never | Resolver that created it |
Factories are lazy and synchronous. Asynchronous resource cleanup is supported, but asynchronous factories are not part of the initial API.
Singleton
A singleton is created once and stored by the root container. Its factory always receives the root resolver, even when it is first requested through a child scope. This prevents a singleton from capturing a scoped dependency.
Transient dependencies created by a singleton factory are also owned by the root container.
Scoped
A scoped dependency is created once per child scope. Resolving one directly from the root throws an error instead of creating an implicit root scope.
container.get("database");
// Throws: Cannot resolve scoped dependency "database" outside a scopeTransient
A transient dependency is created for every get call and every time another
factory requests it.
Scopes
The root can create multiple independent scopes, making the container safe for concurrent request handling:
async function handleRequest() {
await using scope = container.createScope();
const first = scope.get("database");
const second = scope.get("database");
console.log(first === second); // true
}
await Promise.all([handleRequest(), handleRequest()]);Each request receives a different database instance. Singleton instances are
shared by all scopes.
Nested scopes are intentionally unsupported. Calling createScope() on a scope
throws. This keeps ownership and disposal boundaries unambiguous.
Use isScope() when code needs to distinguish a root container from a scope:
container.isScope(); // false
const scope = container.createScope();
scope.isScope(); // true
await scope.dispose();Disposal
Containers and scopes implement asynchronous disposal. Created values are disposed in reverse creation order when they define either of the native disposal methods:
class Connection {
async [Symbol.asyncDispose]() {
await this.close();
}
private async close() {
// Close the underlying resource.
}
}Symbol.asyncDispose takes precedence when a value implements both protocols.
Calls to dispose() are idempotent.
const scope = container.createScope();
try {
scope.get("database");
} finally {
await scope.dispose();
}Singletons and root-created transients are disposed with the root. Scoped and scope-created transient values are disposed with their scope. Dispose every scope before disposing the root container.
Resolution Errors
Resolution errors include the dependency path to make configuration failures actionable:
Cannot resolve "database": missing dependency "logger"
Resolution path: users -> database -> loggerCircular dependencies are detected before a factory is invoked again:
Circular dependency detected: users -> database -> usersThe container throws when:
- a key is registered more than once;
- an unknown key is resolved;
- a scoped dependency is resolved from the root;
createScope()is called on a scope;- a circular dependency is detected; or
- a disposed container or scope is used.
New unique registrations may be added to the root at any time. Registrations are not locked after the first resolution or scope creation.
API
createContainer<Services>()
Creates an isolated root container for a service map.
const container = createContainer<Services>();set(key, type, factory)
Registers a lazy factory. The key, lifetime discriminator, factory resolver, and factory return value are type checked.
container.set("logger", "singleton", () => new Logger());The lifetime discriminator is one of:
type Lifetime = "singleton" | "scoped" | "transient";Registrations are represented as a discriminated union:
interface Resolver<Services> {
get<Key extends keyof Services>(key: Key): Services[Key];
isScope(): boolean;
}
type Factory<Services, Value> = (resolver: Resolver<Services>) => Value;
type Registration<Services, Value> =
| { type: "singleton"; factory: Factory<Services, Value> }
| { type: "scoped"; factory: Factory<Services, Value> }
| { type: "transient"; factory: Factory<Services, Value> };set returns the container so registrations can be chained.
get(key)
Resolves a dependency and infers its result from the service map.
const logger = container.get("logger"); // LoggerFactories receive a restricted resolver exposing typed get and isScope
operations, not registration or scope creation.
createScope()
Creates an independent child scope. The root may create any number of scopes. A scope cannot create another scope.
isScope()
Returns false for a root container and true for a child scope.
dispose()
Disposes values owned by the resolver in reverse creation order and returns a
Promise<void>. Repeated calls are safe.
The container and scopes also implement [Symbol.asyncDispose]() for use with
await using.
Comparison
The comparison below focuses on public behavior relevant to this package. It is based on each project's documentation as of August 29, 2026.
| Feature | @ironbase/di | Awilix | TSyringe | InversifyJS | TypeDI |
| --- | --- | --- | --- | --- | --- |
| Singleton lifetime | Yes | Yes | Yes | Yes | Yes |
| Scoped lifetime | Explicit child scope | Explicit child scope | Container-scoped | Resolution-request scope | Named container |
| Transient lifetime | Yes | Yes | Yes | Yes | Yes |
| Typed string lookup | Service map | Typed cradle | Manual generic | Manual generic | Prefer Token<T> |
| Decorator-free registration | Yes | Yes | Provider API available | Binding API available | Container API available |
| Reflection metadata required | No | No | For decorator usage | For metadata injection | For decorator usage |
| Nested scopes | No | Yes | Yes | Container hierarchy | Multiple containers |
| Native disposal symbols | Yes | No | No | No | No |
| Async dependency graph | No | No | No | Yes | No |
The goal is not to cover every DI pattern. @ironbase/di focuses on explicit
factories, strict scope boundaries, native cleanup, and a small API.
Non-Goals
The initial package intentionally does not include:
- decorators or reflection metadata;
- automatic constructor inspection;
- a global default container;
- nested scopes or child registration overrides;
- asynchronous factories;
- module scanning or automatic registration;
- activation hooks or interception; or
- multiple registrations for one key.
These constraints keep dependency construction visible and the implementation small.
