Type-safe dependency injection for TypeScript. No decorators. No tokens. No reflect-metadata. Just a fluent builder, a Proxy, and full type inference. ~5 KB gzip, zero runtime dependencies.
import{container}from'inwire';constapp=container().add('logger',()=>newLogger()).add('db',(c)=>newDatabase(c.logger))// c.logger is typed.add('users',(c)=>newUserService(c.db))// c.db is typed.build();app.users.findById('42');// lazy, singleton, fully typed| inwire | typical DI container | |
|---|---|---|
| Type inference | Full — c.db autocompletes from .add() history | Manual generics or token strings |
| Decorators | None | Required (@Injectable, @Inject) |
| Runtime metadata | None | reflect-metadata polyfill needed |
| Circular deps | Caught with full chain + fix hint | Stack overflow or cryptic crash |
| Async lifecycle | First-class preload() with topological parallelism | Manual Promise.all plumbing |
| Introspection | inspect() returns JSON graph for LLMs / dashboards | None |
| Bundle size | ~5 KB gzip | 10–50 KB |
| Runtime | Pure ES2022 — Node, Bun, Deno, Workers, browsers | Often Node-only |
The dependency graph is a side product: a tracking Proxy records which keys each factory accesses, so inspect() returns the real graph without you ever annotating it.
pnpm add inwire # or npm i inwire / bun add inwireRequires TypeScript ≥ 5.0 and an ESM-aware bundler / runtime.
For real-world apps, organize bindings per module file with Pinia-style global type augmentation. Each file declares what it provides by augmenting AppDeps; defineModule() types the factory's c against the merged interface — cross-module references resolve regardless of import order.
// modules/persistence.module.tsimport{defineModule}from'inwire';importtype{IUserRepository}from'../contracts/IUserRepository';import{DrizzleUserRepository}from'../infrastructure/DrizzleUserRepository';declare module 'inwire'{interfaceAppDeps{IUserRepository: IUserRepository;}}exportconstpersistenceModule=defineModule()((b)=>b.add('IUserRepository',(): IUserRepository=>newDrizzleUserRepository()),);// modules/auth.module.tsimport{defineModule}from'inwire';importtype{IAuthProvider}from'../contracts/IAuthProvider';import{BetterAuthProvider}from'../infrastructure/BetterAuthProvider';import{SignInUseCase}from'../application/SignInUseCase';declare module 'inwire'{interfaceAppDeps{IAuthProvider: IAuthProvider;SignInUseCase: SignInUseCase;}}exportconstauthModule=defineModule()((b)=>b.add('IAuthProvider',(): IAuthProvider=>newBetterAuthProvider()).add('SignInUseCase',(c)=>newSignInUseCase(c.IUserRepository,c.IAuthProvider)),// ^^^^^^^^^^^^^^^^^^^^// provided by persistenceModule — typed via merged AppDeps);// container.ts — single source of truthimport{container}from'inwire';import{persistenceModule}from'./modules/persistence.module';import{authModule}from'./modules/auth.module';exportconstdi=container().addModule(persistenceModule).addModule(authModule).build();exporttypeDi=typeofdi;// derived — never hand-writtenWhy this scales:
- Locality. Each module is self-contained: it states what it provides, in its own file. No global shape interface to maintain.
- Order-independent.
authModulereferencesc.IUserRepositoryeven ifpersistenceModuleis added later, in any file. - Familiar pattern. Mirrors Pinia's
PiniaCustomPropertiesand Vue'sComponentCustomProperties. Augmentations are erased after type-check — zero runtime cost. - Derived types.
type Di = typeof di— add a binding,Digrows; remove one, it shrinks. The compiler does the bookkeeping.
Other patterns are supported when this one doesn't fit — see Modules reference.
container() returns a fluent builder. Each .add(key, factory) accumulates the type so the next factory's c argument is typed with everything declared so far. .build() wraps the factories in an ES Proxy: property access triggers lazy resolution and caches the result.
constapp=container().add('db',()=>newDatabase()).build();app.db;// first access → factory runs, instance cachedapp.db;// subsequent access → cached instance returnedThe c argument passed to each factory is itself a tracking Proxy. Every property access is recorded — that's how inspect() returns the real graph without you annotating it.
constapp=container().add('db',()=>newDatabase()).add('repo',(c)=>newUserRepo(c.db))// c.db touched → graph: repo → [db].build();app.inspect();// { providers: { db: { deps: [], ... }, repo: { deps: ['db'], ... } }}constapp=container().add('db',()=>newDatabase())// singleton (cached).addTransient('requestId',()=>crypto.randomUUID())// transient (fresh each access).build();app.db===app.db;// trueapp.requestId===app.requestId;// falseFor scope() and extend(), use the transient() wrapper:
import{transient}from'inwire';constscoped=app.extend({timestamp: transient(()=>Date.now()),});A non-function value passed to .add() is registered eagerly (wrapped in () => value):
container().add('config',{port: 3000})// eager — `{ port: 3000 }` is the value.add('db',(c)=>newDatabase(c.config))// lazy — function = factory.build();To register a function as a value, wrap it: .add('handler', () => myFunction).
Implement onInit() / onDestroy() on any class. inwire detects them at runtime — no base class required.
importtype{OnInit,OnDestroy}from'inwire';classDatabaseimplementsOnInit,OnDestroy{asynconInit(){awaitthis.connect();}asynconDestroy(){awaitthis.disconnect();}privateasyncconnect(){/* open the pool */}privateasyncdisconnect(){/* drain the pool */}}CRITICAL gotcha — sync property access cannot await. When you access
app.db,onInit()is called but not awaited. Async errors are silently captured ashealth().warnings. To safely await async startup, usepreload().
preload() is the only way to safely await async onInit(). It runs independent branches in parallel using a topological sort (Kahn's BFS), levels sequentially:
Level 0: [config] ← no deps
Level 1: [db] [cache] ← parallel, both depend on config
Level 2: [api] ← depends on db + cache
awaitapp.preload('db','cache');// specific keysawaitapp.preload();// everythingErrors from onInit() propagate as a single AggregateError if multiple fail. Wrap in try/catch for startup validation.
Canonical boot sequence. For any non-trivial app with async init (DB connections, queue workers, cache warmers), the recommended pattern is:
// 1. Build the container — no I/O happens here, just factory registration.constapp=container().add('config',()=>loadConfig()).add('db',(c)=>newDatabase(c.config))// implements OnInit (connect()).add('cache',(c)=>newRedis(c.config))// implements OnInit (connect()).add('queue',(c)=>newQueueConsumer(c.db))// implements OnInit (start consuming).build();// 2. Preload — runs onInit() in parallel where possible, surfaces errors.try{awaitapp.preload();}catch(err){console.error('Boot failed',err);// AggregateError if multiple onInit() rejectedprocess.exit(1);}// 3. Wire shutdown — LIFO onDestroy() in reverse resolution order.for(constsigof['SIGTERM','SIGINT']asconst){process.on(sig,async()=>{awaitapp.dispose();process.exit(0);});}// 4. App is ready.app.queue.start();Without preload(), async onInit() errors are silently captured as health().warnings — fine for hot reloads, dangerous for production boot.
scope() creates a child container with extra bindings. The child inherits parent singletons via a parent-resolver chain; scoped bindings are isolated per scope.
constrequest=app.scope({requestId: ()=>crypto.randomUUID(),handler: (c)=>newHandler(c.logger,c.requestId),// c is typeof app},{name: 'request-123'},// optional, surfaces in inspect()/toString());request.requestId;// unique per scoperequest.logger;// shared from parentTwo patterns — pick what fits your test:
From scratch — build a parallel container with mocks:
functioncreateTestContainer(){returncontainer().add('logger',()=>({log: ()=>{}}))// silent.add('db',()=>newInMemoryDatabase())// mock.add('users',(c)=>newUserService(c.db,c.logger)).build();}From a real container — override specific keys via .extend():
// production container is the source of truthconsttestApp=realApp.extend({db: ()=>newInMemoryDatabase(),// overrideemailService: ()=>({send: vi.fn()}),// mock});// All other bindings (loggers, repos, services...) come from realApp untouched.testApp.users.signup({email: 'a@b.c'});.extend() shares the parent's singleton cache, so already-resolved real instances are reused. Overridden keys get fresh factories. This is the most ergonomic way to mock a slice of a real container without rebuilding the whole graph.
extend() returns a new container with additional bindings. Unlike scope(), the existing singleton cache is shared — already-resolved instances are reused.
constwithCsv=core.extend({csvParser: (c)=>newCsvParser(c.logger),});constapp=withCsv.extend({jobRunner: transient((c)=>newJobRunner(c.csvParser)),});scope() | extend() | |
|---|---|---|
| Topology | Parent-child chain | Flat merged container |
| Cache | Independent per-scope cache | Shares parent's resolved cache |
| Use for | Per-request isolation | Additive composition / plugins |
Calls onDestroy() on all resolved instances in LIFO order. Resilient: continues on errors, collects them into AggregateError.
process.on('SIGTERM',async()=>{awaitapp.dispose();process.exit(0);});ES2023 explicit resource management — every container implements [Symbol.asyncDispose], so await using auto-disposes when the binding leaves scope:
asyncfunctionhandleRequest(req: Request){await using request=app.scope({requestId: ()=>crypto.randomUUID(),handler: (c)=>newHandler(c.logger,c.requestId),});returnrequest.handler.run(req);}// request.dispose() fires automatically here, even on throwRequires TypeScript ≥ 5.2 and a runtime with Symbol.asyncDispose (Node ≥ 20.4, Bun, Deno).
app.db;// creates instanceapp.reset('db');// invalidates cache for a specific keyapp.db;// creates a NEW instance (factory re-runs, onInit re-fires)app.reset();// no args → invalidates ALL cached singletons in this scopereset() is scope-local — it doesn't affect parent caches. The no-arg variant also clears initState, the recorded dependency graph, and any captured warnings — useful for fully rebuilding state in long-running tests.
app.inspect();// ContainerGraph — full dependency graph (JSON)app.describe('users');// ProviderInfo for one bindingapp.health();// { totalProviders, resolved, unresolved, warnings }String(app);// human-readable one-linerconstgraph=JSON.stringify(app.inspect(),null,2);// Pipe to an LLM, render in a dashboard, diff in CI.inwire offers four ways to compose modules. Pinia-style is the recommended default; the rest fit specific situations.
See Modular Setup above for the full recipe. TL;DR:
declare module 'inwire'{interfaceAppDeps{IUserRepository: IUserRepository}}exportconstpersistenceModule=defineModule()((b)=>b.add('IUserRepository',(): IUserRepository=>newDrizzleUserRepository()),);- Each module declares what it provides.
cis typed as the mergedAppDeps.- Cross-module forward references work, order-independent.
When a module's prereqs are a tight, fixed surface and you'd rather not augment a global, declare what the module consumes inline. c is typed locally as TDeps:
constdbModule=defineModule<{logger: Logger}>()((b)=>b.add('db',(c)=>newDatabase(c.logger)).add('cache',(c)=>newRedis(c.logger)),);Trade-offs vs Pinia-style:
| Pattern | Declares | Cross-module forward ref | Global state |
|---|---|---|---|
Pinia-style (defineModule() + declare module) | what the module provides | yes — order-independent | augments inwire's AppDeps |
Local (defineModule<TDeps>()) | what the module consumes | no — prereqs added first | none |
Both modes coexist: passing <TDeps> always overrides the global mode for that module.
Why the double-call signature
defineModule<TDeps>()(fn)? TypeScript's generic inference is all-or-nothing — specifying<TDeps>in a flat single-call signature would force you to write<TBuilt>by hand too, defeating the inference of the.add()chain. The curry splits the two: first call fixesTDeps(or defaults toAppDeps), second call infersTBuiltfrom the factory return. Same workaround used by zod, TanStack Query, RTK. Tracking microsoft/TypeScript#26242.
addModule()does not enforce prereq satisfaction at the type level — missing keys raiseProviderNotFoundErrorat resolution time. This relaxation is what makes Pinia-style forward references possible.
When a module has no prerequisites, define it as a plain builder and merge it:
constdbModule=container().add('db',()=>newDatabase()).add('cache',(c)=>newRedis(c.db));constapp=container().add('logger',()=>newLogger()).merge(dbModule).add('api',(c)=>newApi(c.db,c.logger)).build();Cross-builder dependencies are resolved at build time. Duplicate keys override (last write wins). Reserved keys throw.
Compose post-build using the same builder DX. Each .add() in the callback types c incrementally:
constcore=container().add('logger',()=>newLogger()).build();constwithDb=core.module((b)=>b.add('db',(c)=>newDatabase(c.logger)),);constfull=withDb.module((b)=>b.add('users',(c)=>newUserService(c.db,c.logger)),);module() works on scope() and extend() results too. Internally it delegates to extend() after building the typed factory record.
Older code may show this manual generic — verbose, couples the module to a global AppDeps, forces redeclaring prerequisites:
// ✗ Don't do this — use defineModule() instead.functiondbModule<Textends{logger: Logger}>(b: ContainerBuilder<AppDeps,T>,){returnb.add('db',(c)=>newDatabase(c.logger));}For monolithic, single-file containers (no modules), pass an interface to container<T>() to constrain keys and return types at compile time:
interfaceAppDeps{ILogger: Logger;IDatabase: Database;IUserService: UserService;}constapp=container<AppDeps>().add('ILogger',()=>newConsoleLogger())// key: keyof AppDeps.add('IDatabase',(c)=>newPgDatabase(c.ILogger))// return must match Database.add('IUserService',(c)=>newUserService(c.IDatabase,c.ILogger)).build();app.ILogger;// typed as Logger (interface), not ConsoleLoggerThe string key acts as a token (à la NestJS) but is type-safe at compile time. For multi-module apps, use Pinia-style instead — it scales across files; Contract Mode does not.
Note (Contract Mode typing nuance):
container<AppDeps>().add('db', () => 'postgres')givesc.dbthe literal type'postgres', notstring. The contract constrains what you can add; it doesn't widen the inferred return type.
scope() creates a parent-child resolver chain. Two views coexist — think JS prototype inheritance:
- Own (child's local bindings only):
size,Object.keys(),Symbol.iterator,inspect(),health(),toJSON(), andpreload()with no arguments. - Inherited (walks the parent chain): property access
child.dband theinoperator.
extend() is not affected: it flattens everything into a single resolver, so its "own" view includes all bindings.
constchild=app.scope({extra: ()=>42});child.size;// 1 — only 'extra', the scope's own bindingchild.db;// resolved through the parent chain (inherited)'db'inchild;// true (inherited)Object.keys(child.inspect().providers);// ['extra'] — own view onlyTwo limitations exist today. The runtime works correctly in both cases; only the TypeScript types are affected.
1. Self-referencing in scope() — a factory cannot reference a key added in the same scope() call:
// ✗ TypeScript error — 'requestId' not yet visible in c at this call siteapp.scope({requestId: ()=>crypto.randomUUID(),handler: (c)=>newHandler(c.requestId),// c doesn't include 'requestId' here});// ✓ Split into two scope() callsconsts1=app.scope({requestId: ()=>crypto.randomUUID()});consts2=s1.scope({handler: (c)=>newHandler(c.requestId)});Root cause: making E auto-referential in the scope() generic collapses all ReturnType<E[K]> to unknown.
2. .merge() prerequisites — .merge() is designed for modules with no prerequisites. A standalone builder starts from TBuilt = {}, so its factories cannot declare dependencies on keys from the host. Use defineModule<TDeps>() instead:
// ✗ Type error — 'logger' not in the standalone builder's TBuiltconstwithLog=container().add('repo',(c)=>newRepo(c.logger));// ✓ Use defineModule to declare prereqsconstrepoModule=defineModule<{logger: Logger}>()((b)=>b.add('repo',(c)=>newRepo(c.logger)),);Every error extends ContainerError and carries:
hint: string— actionable fix suggestiondetails: Record<string, unknown>— structured context for programmatic consumption
Designed to be parsed by both humans and LLMs.
app.userServce;// typo// ProviderNotFoundError: Cannot resolve 'userServce'.// Registered: [userService, logger, db]// Did you mean 'userService'?// hint: Add 'userServce' to your container, or fix the typo.Powered by Levenshtein distance. Default threshold: 50% similarity — configurable via container({ similarityThreshold: 0.8 }). Set to 1 to require an exact match (disables suggestions).
// CircularDependencyError: Circular dependency detected while resolving 'authService'.// Cycle: authService → userService → authServiceNo stack overflow, no cryptic crash — just the resolution chain.
scope, extend, module, preload, reset, inspect, describe, health, dispose, toString, toJSON, size cannot be used as dependency keys.
container().add('inspect',()=>'foo');// ReservedKeyError: 'inspect' is a reserved container method.// hint: Rename, e.g. 'inspectService' or 'myInspect'.A singleton depending on a transient freezes the transient value. Surface via health():
app.health().warnings;// [{// type: 'scope_mismatch',// message: "Singleton 'userService' depends on transient 'requestId'.",// details: { singleton: 'userService', transient: 'requestId' },// }]When onInit() rejects during lazy access (no preload()), the rejection is captured as a warning rather than crashing your app.
app.health().warnings;// [{ type: 'async_init_error', message: "onInit() for 'db' rejected: connection refused", ... }]Use preload() to surface these as proper errors.
.add() and .addTransient() throw DuplicateKeyError if the key is already registered — no silent overwrites:
container().add('logger',()=>newConsoleLogger()).add('logger',()=>newFileLogger());// throws DuplicateKeyErrorFor intentional overrides (test doubles, plugins, environment-specific bindings), use .extend() or .scope() on a built container — both are documented override mechanisms.
| Error | Thrown when |
|---|---|
ContainerError | Base class for all errors. Every subclass carries hint + details. |
ContainerConfigError | Non-function value passed to scope() / extend() deps |
ReservedKeyError | Reserved method name used as a key |
DuplicateKeyError | .add() or .addTransient() called twice with the same key |
ProviderNotFoundError | Key not registered (with fuzzy suggestion) |
CircularDependencyError | Cycle detected during resolution |
UndefinedReturnError | Factory returned undefined |
FactoryError | Factory threw (wraps original error) |
TopologicalSortError | Topological sort in preload() could not complete (defensive guard — cycle detection via CircularDependencyError normally fires first) |
ScopeMismatchWarning | Singleton depends on transient (surfaced via health().warnings). Carries hint with refactor suggestions. |
AsyncInitErrorWarning | Async onInit() rejected during lazy access (surfaced via health().warnings). Carries hint pointing to preload(). |
| Example | Run | Showcases |
|---|---|---|
| 06-pinia-augmentation.ts ★ | npm run example:pinia | Recommended modular pattern.declare module 'inwire' per file, order-independent cross-module typing |
| 05-zod-style-typing.ts | npm run example:typing | type Di = typeof di derivation, Clean Arch contracts |
| 04-modules.ts | npm run example:modules | defineModule<TDeps>(), .merge(), module() post-build |
| 03-plugin-system.ts | npm run example:plugin | Extend chain, scoped jobs, JSON graph for LLM |
| 02-modular-testing.ts | npm run example:test | Free mode, instance values, test overrides |
| 01-web-service.ts | npm run example:web | Contract mode, lifecycle, dependency inversion |
| Export | Kind | Description |
|---|---|---|
container<T?>(options?) | function | Creates a ContainerBuilder. Pass T for Contract Mode. options?: ContainerOptions — e.g. container({ similarityThreshold: 0.8 }). |
ContainerBuilder | class | Fluent builder class (rarely instantiated directly — container() is the entry point). Exported for type-only use and advanced composition. |
defineModule<TDeps?>()(fn) | function | Defines a typed reusable module. See Modules reference. |
transient(factory) | function | Marks a factory as transient (for scope() / extend()). |
| Method | Description |
|---|---|
.add(key, factoryOrInstance) | Register a binding. Function = lazy factory; non-function = eager instance. |
.addTransient(key, factory) | Register a transient binding (fresh each access). |
.addModule(module) | Apply a Module (typically from defineModule()). |
.merge(otherBuilder) | Fuse a standalone builder's factories into this one. |
.build() | Build and return the container. |
| Method | Description |
|---|---|
.scope(extra, options?) | Child container with additional deps. Inherits parent singletons via parent chain. |
.extend(extra) | New container with additional deps. Shares singleton cache. |
.module(fn) | Post-build ContainerBuilder for typed c accumulation. Delegates to extend(). |
.preload(...keys) | Eagerly resolve and awaitonInit(). No args = preload all. |
.reset(...keys) | Invalidate cached singletons. Scope-local. |
.inspect() | Full dependency graph (ContainerGraph). |
.describe(key) | Single binding info (ProviderInfo). |
.health() | Health snapshot + warnings (ContainerHealth). |
.dispose() | LIFO onDestroy() on all resolved instances. |
[Symbol.asyncDispose]() | Alias of .dispose() — enables await using container = ... (ES2023). |
.size | readonly number — count of registered providers. |
.toJSON() | Plain object of currently resolved (cached) deps. Does not trigger lazy resolution. Makes JSON.stringify(container) work. |
[Symbol.iterator]() | Yields [key, value] pairs for every registered provider. Triggers lazy resolution. Enables for...of, spread, Array.from. |
| Type | Description |
|---|---|
AppDeps | Augmentable global interface for Pinia-style typing. |
Container<T> | T & IContainer<T> — resolved deps + container methods. |
ContainerBuilder<TContract, TBuilt> | Fluent builder (also passed to module() callbacks). |
IContainer<T> | Container methods interface (scope, extend, module, preload, reset, inspect, describe, health, dispose, toJSON, size, [Symbol.iterator], [Symbol.asyncDispose]). |
IContainerBuilder<TContract, TBuilt> | Domain-level builder interface (used in module() / defineModule() callbacks). |
ContainerOptions | { similarityThreshold?: number } — options for container(). Controls the fuzzy suggestion threshold in ProviderNotFoundError (default 0.5, range 0–1). Propagated through scope(), extend(), and module(). |
Module<TDeps, TBuilt> | Module shape returned by defineModule(). |
InferModuleDeps<M> / InferModuleBuilt<M> | Extract a module's prereqs / full output. |
Factory<T> | Raw factory signature (c: unknown) => T. |
OnInit / OnDestroy | Lifecycle interfaces (duck-typed). |
ContainerGraph | Return of inspect() — { name?, providers }. |
ContainerHealth | Return of health() — { totalProviders, resolved, unresolved, warnings }. |
ContainerWarning | { type: 'scope_mismatch' | 'async_init_error', message, details }. |
ProviderInfo | Return of describe() — { key, resolved, deps, scope }. |
ScopeOptions | { name?: string }. |
Clean Architecture with an enforced one-way dependency rule.
src/
index.ts # public barrel — only file consumers see
domain/ # pure contracts — no framework deps
types.ts # barrel re-exporting types/public.ts + types/internal.ts
types/public.ts # Container, IContainer, IContainerBuilder, AppDeps, helpers
types/internal.ts # IResolver, ICycleDetector, IDependencyTracker, IValidator
errors.ts # 9 error classes (ContainerError abstract + 8 concrete) + 2 warnings, each with hint + details
lifecycle.ts # OnInit / OnDestroy (duck-typed)
validation.ts # Validator (configurable similarity threshold), Levenshtein
infrastructure/ # mechanisms — depends on domain/ only
resolver.ts # lazy resolution, singleton cache, parent chain
cycle-detector.ts # circular dependency detection
dependency-tracker.ts # tracking Proxy + auto-built dependency graph
transient.ts # transient() marker (Symbol.for-based)
application/ # orchestration — depends on domain/ + infrastructure/
container-builder.ts # ContainerBuilder + container() factory ▸ Composition Root
container-proxy.ts # Proxy construction + dispatch ▸ Composition Root
scoper.ts # builds child resolvers for .scope() ▸ Composition Root
extender.ts # builds merged resolvers for .extend() ▸ Composition Root
define-module.ts # defineModule() — both modes
preloader.ts # topological sort (Kahn) + parallel onInit
disposer.ts # reverse-order onDestroy + resilient errors
introspection.ts # inspect / describe / health / toString
The Resolver receives its collaborators via constructor injection — no internal new, no hidden coupling. Application code depends on IResolver, never on the concrete class. The four Composition Roots (container-builder.ts, container-proxy.ts, scoper.ts, extender.ts) are the only files allowed to instantiate concrete infrastructure (Resolver, CycleDetector, DependencyTracker).
This package ships llms.txt files for AI-assisted development:
llms.txt— Concise index following the llms.txt standardllms-full.txt— Complete API reference optimized for LLM context windows
Compatible with Context7 and any tool that supports the llms.txt standard. The inspect() output is also designed to be piped directly into an LLM for architecture analysis.
MIT