A module-based dependency-injection container for TypeScript — ports as the vocabulary your application defines, providers bound at one edge, and Result instead of throws.
Ports are the vocabulary an application defines for what it needs — never what an
adapter happens to provide. Providers bind a port to a concrete construction: a
value, a factory, a class, or a resource with its own teardown. Modules group
providers and declare what they need from elsewhere (imports) and what they let
outside modules see (exports) — everything else stays private, even though the
built container is a single flat map at runtime. Every wiring mistake this package
can catch — a missing dependency, an internal port leaking out of a module, a
re-export of something never imported — is a compile error, not a runtime surprise.
What it cannot catch at compile time (a cycle, two providers registered for the same
port) is caught before any factory runs, as a defect, not silently. Nothing throws:
every fallible operation returns an
unthrownResult.
pnpm add @btravstack/di unthrownunthrown is a peer dependency — install both.
import{Module,Port,Provider,typeServiceOf}from"@btravstack/di";import{Err,Ok,typeAsyncResult}from"unthrown";// 1. Ports: named by the domain, never by whatever will eventually implement them.classOrderRepositoryextendsPort("OrderRepository")<{readonlyfindById: (id: string)=>AsyncResult<Order,OrderNotFound>;}>{}classGetOrderextendsPort("GetOrder")<{readonlyexecute: (id: string)=>AsyncResult<Order,OrderNotFound>;}>{}// 2. Application: depends on the port, never on an adapter.classGetOrderInteractor{privatereadonlyorders: ServiceOf<OrderRepository>;constructor(orders: ServiceOf<OrderRepository>){this.orders=orders;}execute(id: string): AsyncResult<Order,OrderNotFound>{returnthis.orders.findById(id);}}// 3. Adapter: bound at one edge. A resourceful one puts `Scope` in `Needs`.constPersistence=Module("Persistence")({provides: [Provider(Database)([AppConfig],{acquire: (config)=>openPool(config.dbUrl),release: (pool)=>pool.close(),}),Provider(OrderRepository)([Database],{sync: (db)=>({findById: (id)=>db.query(id)}),}),],exports: [OrderRepository],// Database stays internal to this module.});// 4. Composition root: `Scope` in `Needs` forces `Module.scoped`, which opens// a scope and guarantees it is closed — success, failure, or partial// failure — before this call resolves.constApp=Module("App")({imports: [Persistence],provides: [Provider(GetOrder)([OrderRepository],{class: GetOrderInteractor}),],exports: [GetOrder],});constresult=awaitModule.scoped(App,(ctx)=>ctx.get(GetOrder).execute("o-1"),);Every wiring mistake this package can catch is a compile error. Swap Persistence
for a resource-free in-memory module and Module.build (no scope, no teardown)
compiles too — but passing the resourceful module to Module.build does not:
Needs still contains Scope, so the call is rejected before anything runs.
See packages/di for the full package README — the construction
family (value / sync / make / class / acquire+release), set ports
(Port.many / Provider.member), and Module.forkScope for per-request scopes.
See CONTRIBUTING.md for the contribution gate, the commit convention, and how the Node version matrix is chosen.
MIT © Benoit TRAVERS