Skip to content
Buy me a coffee

@nodelibraries/ioc

Zero dependencies · no decorators · ESM + CJS

Dependency injection
that fails before production does.

A type-safe IoC container for TypeScript and JavaScript, modeled on .NET's ServiceCollection. Singleton, scoped and transient lifetimes, keyed services, lifecycle hooks and circular dependencies. No reflect-metadata.

app.ts
import { ServiceCollection, createToken } from '@nodelibraries/ioc';
interface Logger { log(msg: string): void }
const Logger = createToken<Logger>('Logger');
class Db { onDestroy() { console.log('Db closed') } }
class Users {
constructor(readonly db: Db, readonly logger: Logger) {}
}
const provider = new ServiceCollection()
.addSingleton(Logger, () => ({ log: console.log }))
.addScoped(Db)
.addTransient(Users, [Db, Logger]) // checked
.buildServiceProvider({ validateScopes: true });
for (const request of ['GET /a', 'GET /b']) {
await using scope = provider.createScope();
const a = await scope.getRequiredService(Users);
const b = await scope.getRequiredService(Users);
console.log(request, a === b, a.db === b.db);
}
node app.ts
$ node app.ts
0dependencies
3lifetimes
6typed errors
Node ≥ 20Bun, Deno, browsers
ESM + CJStyped, tree-shakeable

What it does

Wire it once. Know it is right.

Typed

A wrong dependency is a compile error, not a 3 a.m. stack trace.

List one token per constructor parameter. TypeScript checks the list against the constructor, and createToken<T>() gives interfaces a typed token that resolves to T with no generic argument.

Read more
const Logger = createToken<Logger>('Logger');
services.addSingleton(Logger, ConsoleLogger);
services.addScoped(UserRepository, [Logger]); // checked
const repo = await scope.getRequiredService(UserRepository); // UserRepository
Lifetimes

Singleton, scoped, transient. The .NET model.

One instance for the app, one per request scope, or a new one every time. Scopes support await using.

Read more
await using scope = provider.createScope();
const uow = await scope.getRequiredService(UnitOfWork);
Cycles

Circular dependencies that just work.

Any lifetime, any length. Identity holds and #private fields work. Touch a half-built partner in a constructor and you get the path, not undefined.

Read more
services.addSingleton(Orders, [Payments]);
services.addSingleton(Payments, [Orders]);
orders.payments.orders === orders; // true
Validation

Mistakes fail at startup, with a path.

validateOnBuild reports missing dependencies and scoped services captured by singletons before the first request.

Read more
services.buildServiceProvider({
validateScopes: true,
validateOnBuild: true, // throws ValidationError
});
Disposal

Clean shutdown, in the right order.

onDestroy, Symbol.asyncDispose and Symbol.dispose run in reverse creation order, every one of them even if some throw.

Read more
class Db {
async onInit() { await this.connect(); }
async onDestroy() { await this.close(); }
}
Concurrency

Correct under Promise.all.

Concurrent resolutions of a singleton or scoped service share one instance. Concurrent chains that would wait on each other are detected instead of deadlocking.

Read more
const [a, b] = await Promise.all([
provider.getRequiredService(Db),
provider.getRequiredService(Db),
]);
a === b; // true: built once

The toolbox

Know .NET's ServiceCollection? You know the API.

Register

  • addSingleton · addScoped · addTransientClass, implementation, factory
  • tryAdd*Defaults that apps can override
  • addKeyed*Several variants by key
  • addValueConfig and constants
  • remove · replace · hasManage registrations

Resolve

  • getRequiredServiceTyped, throws with a path
  • getServiceOr undefined
  • getServicesEvery implementation
  • getRequiredKeyedServiceBy key
  • isServiceRegistered?

Scope & lifecycle

  • createScopeOne per request
  • await usingDispose automatically
  • onInit · onDestroyLifecycle hooks
  • Symbol.asyncDisposeStandard disposal
  • dispose()Reverse creation order

Diagnose

  • validateScopesCaptive dependencies
  • validateOnBuildMissing registrations
  • getDependencyTreeWhat builds what
  • getCircularDependenciesEvery cycle
  • IocError + 6 subclassesTyped errors

In code

Real apps, a few lines each.

// One scope per request, disposed when the response is done.
app.use((req, res, next) => {
const scope = provider.createScope();
req.services = scope;
res.on('close', () => void scope.dispose());
next();
});
app.get('/users/:id', async (req, res) => {
const users = await req.services.getRequiredService(UserService);
res.json(await users.find(req.params.id));
});

Get started

Register a class. Resolve it. Grow from there.

Start with two services and a scope. Turn on validateOnBuild the day the graph gets big.