@cleverbrush/di
.NET-style dependency injection for TypeScript — schema-driven service registration, three lifetimes, function injection.
💡 Why @cleverbrush/di?
The Problem
As applications grow, manually wiring dependencies becomes painful. You end up threading configuration, loggers, and database connections through constructor chains. Lifetimes are managed ad-hoc — some services need to be shared, others need to be fresh per request, and keeping track of when to create and dispose them is error-prone.
The Solution
@cleverbrush/dibrings .NET's proven dependency injection model to TypeScript. Instead of inventing a new token system, it uses schema instances as service keys — the same @cleverbrush/schema objects that describe your data also identify your services. Register with a lifetime, build a provider, and resolve — fully typed, no decorators, no magic strings.
Three Lifetimes
- Singleton — one instance for the entire application lifetime. Created on first resolution, reused from then on.
- Scoped — one instance per scope (e.g. per HTTP request). Ideal for database connections and unit-of-work patterns.
- Transient — a fresh instance on every resolution. Use for lightweight, stateless services.
Function Injection
Use FunctionSchemaBuilderto describe a function's dependencies as parameter schemas. The container resolves each parameter and calls your implementation with the resolved values — fully typed via InferType.
Automatic Disposal
Scoped services that implement Symbol.dispose or Symbol.asyncDispose are automatically cleaned up when the scope exits. Works with the using keyword for zero-boilerplate resource management.
Quick Start
Schemas are immutable — they work as safe, typed DI keys. Use .hasType() to brand a schema with a real class type for full autocomplete:
import { ServiceCollection } from '@cleverbrush/di';
import { object, string, number } from '@cleverbrush/schema';
import { Logger } from './Logger';
import { AppConfig } from './AppConfig';
// Schema instances as service keys — .hasType() gives real class typing
const ILogger = object().hasType<typeof Logger>();
const IConfig = object({ port: number(), host: string() });
const services = new ServiceCollection();
services.addSingleton(IConfig, { port: 3000, host: 'localhost' });
services.addSingleton(ILogger, () => new Logger());
const provider = services.buildServiceProvider();
const config = provider.get(IConfig); // typed: { port: number, host: string }
const logger = provider.get(ILogger); // typed: LoggerDI with Endpoint Handlers
The real power of @cleverbrush/di shows when paired with @cleverbrush/server. Use .inject() on an endpoint to declare services — they are resolved per request and passed as the second handler argument:
import { endpoint, createServer } from '@cleverbrush/server';
import { object, number } from '@cleverbrush/schema';
import { UserRepository } from './UserRepository';
import { EmailService } from './EmailService';
// Schema keys branded with real types
const IUserRepo = object().hasType<typeof UserRepository>();
const IEmailSvc = object().hasType<typeof EmailService>();
const GetUser = endpoint
.get('/api/users')
.query(object({ id: number().coerce() }))
.inject({ repo: IUserRepo });
const CreateUser = endpoint
.post('/api/users')
.body(object({ name: string(), email: string() }))
.inject({ repo: IUserRepo, email: IEmailSvc });
const server = createServer();
server
.services(svc => {
svc.addSingleton(IUserRepo, () => new UserRepository());
svc.addSingleton(IEmailSvc, () => new EmailService());
})
.handle(GetUser, ({ query }, { repo }) => {
// repo is typed as UserRepository
return repo.findById(query.id);
})
.handle(CreateUser, async ({ body }, { repo, email }) => {
// repo: UserRepository, email: EmailService
const user = await repo.create(body);
await email.sendWelcome(user.email);
return user;
});Scoped Services — Per-Request Lifecycle
Register services as scoped to get a fresh instance per HTTP request. The server creates a scope automatically for each request and disposes it when the response is sent:
import { object } from '@cleverbrush/schema';
import { DbContext } from './DbContext';
const IDbContext = object().hasType<typeof DbContext>();
server
.services(svc => {
// Fresh connection per request, disposed on response
svc.addScoped(IDbContext, () => {
const db = new DbContext();
return Object.assign(db, {
[Symbol.asyncDispose]: () => db.close()
});
});
})
.handle(
endpoint
.post('/api/orders')
.body(OrderSchema)
.inject({ db: IDbContext }),
async ({ body }, { db }) => {
// db is a fresh DbContext for this request
return db.orders.create(body);
}
);Three Lifetimes
const services = new ServiceCollection();
// Singleton — one instance for the entire app
services.addSingleton(IConfig, { port: 3000, host: 'localhost' });
// Scoped — one instance per scope (per HTTP request)
services.addScoped(IDbContext, () => new DbContext());
// Transient — fresh instance on every resolution
services.addTransient(IRequestId, () => ({ id: crypto.randomUUID() }));Function Injection
Describe a function's dependencies using FunctionSchemaBuilder. The container resolves each parameter schema and calls your implementation:
import { func } from '@cleverbrush/schema';
const handler = func()
.addParameter(ILogger)
.addParameter(IConfig)
.hasReturnType(string());
// All parameters are resolved from the container
const result = provider.invoke(handler, (logger, config) => {
logger.info(`Running on port ${config.port}`);
return 'ok';
});
// result is typed as string