.NET-style dependency injection for TypeScript — schema-driven service registration, three lifetimes, function injection.
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.
@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.
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.
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.
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: LoggerThe 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;
});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);
}
);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() }));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