Why Conduit?
Manual dependency wiring often spreads across modules, making lifetimes and teardown behavior difficult to reason about in larger systems.
ts
// Before — manual wiring, no lifecycle, no type safety
const logger = new ConsoleLogger();
const db = await connectDb(process.env.DATABASE_URL);
const repo = new UserRepo(db, logger);
const service = new UserService(repo, logger);
// cleanup is your problem
// After — explicit tokens, typed resolution, disposal hooks
const container = createContainer();
container.value(Logger, new ConsoleLogger());
container.factory(Db, () => connectDb(process.env.DATABASE_URL), { dispose: (db) => db.close() });
container.factory(UserRepo, async (r) => {
const [db, logger] = await Promise.all([r.resolve(Db), r.resolve(Logger)]);
return new UserRepo(db, logger);
});
container.factory(UserService, async (r) => {
const [repo, logger] = await Promise.all([r.resolve(UserRepo), r.resolve(Logger)]);
return new UserService(repo, logger);
});
const service = await container.resolve(UserService);
await container.dispose(); // all hooks run automatically| Feature | Conduit | tsyringe | InversifyJS |
|---|---|---|---|
| Bundle size | 2.6 KB | ~6 kB | ~45 kB |
| Typed token ergonomics | Partial | Partial | |
| Async-first resolution | Partial | Partial | |
| Child container scopes | |||
| Explicit disposal lifecycle | Partial | ||
| Decorator-free usage | |||
| Zero dependencies |
Use Conduit when you need a compact typed container with explicit scopes and lifecycle control.
Consider decorator-heavy DI frameworks when your project is already standardized around metadata/decorator injection patterns.
Installation
sh
pnpm add @vielzeug/conduitsh
npm install @vielzeug/conduitsh
yarn add @vielzeug/conduitQuick Start
ts
import { createContainer, token } from '@vielzeug/conduit';
const Logger = token<{ log(message: string): void }>('Logger');
const Service = token<{ run(): Promise<void> }>('Service');
const container = createContainer();
container.value(Logger, console);
container.factory(Service, async (r) => {
const logger = await r.resolve(Logger);
return { run: async () => logger.log('Running service') };
});
const service = await container.resolve(Service);
await service.run();
await container.dispose();Features
- Small core API —
token,scope,createContainer, and a focused set of container methods - Typed dependency contracts via Symbol tokens with phantom types
- Factory functions receive a
FactoryResolver— dependencies resolved lazily viar.resolve(Token) - Named scope tokens via
scope()andcreateScope()for explicit lifecycle isolation - Async-first resolution with singleton deduplication for concurrent callers
- Sync resolution path (
resolveSync) for hot paths after warm-up — rethrows cached rejections for failed singletons - Free-function helpers:
resolveSyncOptional,resolveSyncOrDefault,resolveOptional,resolveOrDefault,tryResolve,trySyncResolve resolveMany()to resolve multiple tokens in parallel with typed tuplesresolveAll()to eagerly warm all singletons; pass{ includeScoped: true }to also pre-warm named-scope factoriesInferTokenTypes<T>utility type to infer a typed tuple from a token array- Registration existence check (
has) without triggering factory execution ContainerModule+loadModules()for grouping and async provider setupfreeze()to lock the container after startup; idempotent — safe to call multiple times; declaredeps:on factories for static cycle detection at freeze timeinspect()to get a serializable dependency graphon()to subscribe to container lifecycle events (each event carries asourcefield)onResolve()interceptor called after every successful resolution — for telemetry and hot-path observability- Dispose hooks on both factory and value registrations; failures warn in dev instead of throwing
- Named scope containers for request/component/test scope boundaries
- Explicit disposal lifecycle with
Symbol.asyncDisposesupport