-
Notifications
You must be signed in to change notification settings - Fork 2
Doctor
Lightweight dependency injection for Deno, Bun, Node.js, Cloudflare
Workers, and browsers — no reflect-metadata, no legacy decorators,
no build flags.
Doctor is a small DI container built around three primitives:
-
@Vial(mode)— class decorator that registers a class under a lifecycle (SINGLETON,SCOPED, orTRANSIENT). -
label+Doctor.stock— register a ready-made value (or a labelled factory) under a typed label, no class required. -
inject(target)— typed resolution by label, by class, or by import-free class-name token. Used as a field initializer or constructor default parameter, it wires an instance while it constructs — that IS the injection mechanism, there is no separate injection step.
import { inject, Vial } from '@tundralibs/doctor';
@Vial('SINGLETON')
class Logger {
log(msg: string) {
console.log(`[log] ${msg}`);
}
}
class App {
logger = inject(Logger); // resolves while `new App()` runs — typed Logger
start() {
this.logger.log('app started');
}
}
new App().start(); // [log] app startedThe registry is a process-wide singleton exported as Doctor.
Decorators talk to it; consumers usually don't have to.
Design rule: decorators RECORD, they never SUPPLY VALUES. @Vial
only registers the class. There is deliberately no @Dose-style
member decorator handing you the value, because Bun currently
miscompiles value-supplying member decorators whenever a file contains
more than one decorated class
(oven-sh/bun#30326) —
the last class's initializer silently replaces everyone else's.
inject() initializers are plain expressions, immune by construction,
and shorter anyway.
Doctor 1.1 drops the legacy-decorator machinery — experimentalDecorators,
emitDecoratorMetadata, and reflect-metadata — entirely:
| 1.0.x | 1.1+ |
|---|---|
@Dose() logger!: Logger |
logger = inject(Logger) |
@Inoculate() on the class |
nothing — inject() fields wire themselves on new
|
@Inoculate('scope') |
inject(Db, 'scope') per field, or Doctor.resolve(Class, 'scope')
|
Doctor.treat(instance) |
removed — injection happens during construction |
import 'reflect-metadata' |
removed — no runtime dependency |
experimentalDecorators: true (tsconfig) |
must be off — @Vial is a TC39 standard decorator |
MissingMetadataError, MissingDesignTypeError
|
removed — their failure modes no longer exist |
| SINGLETON ↔ SINGLETON cycles resolved | eager cycles throw CircularDependencyError; break with a lazy getter
|
Prescription type |
removed |
Deno:
deno add @tundralibs/doctorBun:
bunx jsr add @tundralibs/doctorNode.js:
npx jsr add @tundralibs/doctor@Vial is a TC39 (stage-3) standard decorator — the default in
TypeScript 5+, Deno, Bun, esbuild, and tsx. There is nothing to turn
ON; make sure the legacy flag is not turned on:
| Toolchain | Works |
|---|---|
| Deno | ✅ |
| Bun | ✅ |
Node — tsc / ts-node
|
✅ |
Node + tsx, esbuild, Vite |
✅ |
(1.0.x required emitDecoratorMetadata, which tsx/esbuild can never
emit — that row was a ❌. 1.1 removes the requirement.)
Pin a target. "TC39 decorator" describes the syntax, not
something browsers or workerd execute natively — no shipping runtime
does yet, so a bundler must still lower it to plain JS. Left at its
default, esbuild assumes native support and passes the syntax
through unchanged, which is a hard SyntaxError at load time
everywhere:
// esbuild — either the CLI flag or the JS API option
esbuild.build({
target: 'es2022', // or your bundler's equivalent
// ...
});Verified directly: a @Vial/inject() consumer bundled with
--target=es2022 runs correctly in a real browser tab and in a
workerd-shaped environment (process, Bun, Deno, window, and
document all absent). Doctor itself has zero runtime dependencies
beyond @tundralibs/utils's BaseError — imported from its narrow
@tundralibs/utils/BaseError subpath, not the full barrel, so
getFreePort's Node-builtin loading code (node:net/tls/…) never
enters the bundle. Confirmed empirically: the barrel import pulled
those strings in regardless of tree-shaking (they're module-level,
so not provably side-effect-free), the narrow subpath doesn't.
class Handler {
// EAGER — field initializer. Resolves while `new` runs; a missing
// registration fails loudly at construction.
logger = inject(Logger);
// EAGER — constructor default parameter. Same timing; handy when
// tests want to pass a double explicitly: new Handler(fakeDb).
constructor(private db = inject(Db)) {}
// LAZY — memoizing getter. Resolves on FIRST ACCESS.
private __audit?: Audit;
get audit(): Audit {
return this.__audit ??= inject(Audit, 'jobs');
}
}Reach for the lazy-getter idiom when you need to:
-
break a dependency cycle — two eager
inject()s pointing at each other throwCircularDependencyError(each side re-enters the other's still-running construction); a getter on one side defers its resolution until both instances exist; -
register after construction — the vial only has to exist by
first access, not by
new; -
keep a dependency out of serialization — a getter lives on the
prototype, so
JSON.stringify/spread skip it; an eager field is an ordinary enumerable property.
Two rules come with lazy: give it an explicit scope when the
dependency is SCOPED (first access usually happens outside any
operation, where there is no ambient scope
to inherit), and call Doctor.checkup()
at startup so a missing registration still fails at boot rather than
on first use.
| Mode | One instance per … | Use for |
|---|---|---|
SINGLETON |
Process | Stateless services: loggers, config readers |
SCOPED |
Named scope | Per-request state: DB connections, sessions |
TRANSIENT |
Resolution call | Lightweight throwaway objects: validators |
Singletons are constructed lazily on first resolution and cached on successful construction — a failed construction caches nothing, so registering the missing dependency and retrying just works.
Depending on a SCOPED vial from a SINGLETON is a
captive-dependency hazard: the singleton is built exactly once, under
whichever scope its first resolution happens to carry, and that
scope's instance stays captive in the singleton for its whole
lifetime. SCOPED resolutions themselves always require a scope —
asking for a SCOPED vial without one throws
ScopeRequiredError.
Every Doctor.dispense(type, scope) / Doctor.resolve(type, scope)
call makes its scope the ambient operation scope while it
constructs. Any inject() that runs during that construction — field
initializers, constructor defaults, nested vials' own initializers —
and names no scope of its own inherits it:
class UserHandler {
db = inject(Database); // SCOPED — no scope named here
repo = inject(UserRepository); // TRANSIENT, whose own fields inject Database
}
const h = Doctor.resolve(UserHandler, `req-${id}`);
h.db === h.repo.db; // true — both resolved under `req-${id}`Precedence: an explicit argument always wins —
inject(Db, 'pinned') resolves under 'pinned' no matter what
operation is in flight. Outside any operation there is no ambient
scope, so a scope-less inject() of a SCOPED vial throws
ScopeRequiredError — loudly, at new.
End a request by dropping its scope:
Doctor.discharge(`req-${id}`); // drops every instance in that scopeDoctor.dischargeAll() drops every scope at once — registrations are
untouched. Reach for it between test suites or at shutdown, not per request:
it does not know which scopes are still "in use", it just empties all of
them.
Doctor.checkup(); // eagerly dispenses every registered SINGLETONConstructs every SINGLETON now, so a missing registration or a throwing factory fails at startup instead of deep inside the first request that touches it — the counterweight to lazy getters. SCOPED and TRANSIENT vials are skipped (no scope to resolve under; nothing to warm). Returns the number of singletons dispensed.
See it catch a real missing dependency, then pass once the
dependency registers, in the
order-service example (wiring.ts).
Doctor constructs vials with a bare new Klass() by default — a
class needing arguments registers a factory:
class Database {
constructor(public readonly url: string) {}
}
Doctor.prescribe(Database, {
mode: 'SCOPED',
factory: () => new Database(Deno.env.get('DATABASE_URL')!),
});The decorator form accepts the same options object:
@Vial({ mode: 'SCOPED', factory: () => new Database(env.URL) })
class Database { ... }inject('Config') and Doctor.dispenseByName('Config') resolve by
name — a class's name, or a label's — and return unknown. Keep them
for genuinely dynamic wiring (a token read from configuration) and for
breaking a value-import cycle; everywhere else inject(Class) or a
label is typed and survives
minification, which a class name does not.
Not everything is a class Doctor can new: a connected database, a
parsed config object, a client built by an await. Stock such a value
under a typed label and inject it — typed, with no module
augmentation:
import { Doctor, inject, label } from '@tundralibs/doctor';
type BlogDb = { repo(name: string): unknown };
declare const db: BlogDb;
export const Db = label<BlogDb>('Db'); // a typed label: name + contents
Doctor.stock(Db, db); // stock a ready-made value under it
class Posts {
db = inject(Db); // typed as BlogDb
}A stocked value is handed out as-is on every dispense — a singleton by nature (a function is a value too: returned, never called). For a non-class thing that needs a lifecycle, stock a factory and pick a mode; it runs through the same engine as class vials:
import { Doctor, label } from '@tundralibs/doctor';
type Conn = { query(sql: string): unknown };
declare function connect(): Conn;
export const Db = label<Conn>('Db');
Doctor.stock(Db, { mode: 'SCOPED', factory: () => connect() });
// Doctor.dispense(Db, 'req-7') connects once per scope; discharge('req-7') drops it| Form | Lifecycle |
|---|---|
stock(label, value) |
SINGLETON by nature — the same object every time |
stock(Class, instance) |
SINGLETON by nature — under the class token |
stock(label, { mode, factory }) |
SINGLETON / SCOPED / TRANSIENT, as for vials |
A class can be the token too: Doctor.stock(Db, instance) puts a ready
instance under the class itself, so inject(Db) and inject('Db') hand
it out. After Doctor.revoke(Db), that is how a test replaces a @Vial
singleton with a fake — one entry, caches included — instead of
Doctor.reset(), which wipes the whole process-wide registry.
Labels are keyed by name: inject('Db') and
Doctor.dispenseByName('Db') reach the same entry, untyped. Doctor.has(Db) checks for
an optional service; Doctor.revoke(Db) drops one — caches included —
so a test can stock a fake in its place without reset().
Two caveats, both by design:
-
No async factories. Injection runs inside field initializers,
which are synchronous.
awaitthe setup, then stock the result. -
SCOPED means an explicit scope name plus
Doctor.discharge(scope)when you are done — the scope aDoctor.resolve(Handler, scope)operation carries, not an ambient per-request context.
Full API in stock.
Beyond the single process-wide Doctor, a container can mint isolated
children — same registrations, independent instances and overrides — and
a host framework can bridge them across await so inject() still resolves
against the right one:
import { Doctor, inject, label } from '@tundralibs/doctor';
const NAME = label<string>('Name');
Doctor.stock(NAME, 'global-value');
class Greeter {
name = inject(NAME);
}
const tenant = Doctor.createContainer();
tenant.stock(NAME, 'tenant-value'); // overrides NAME for `tenant` only
console.log(tenant.resolve(Greeter).name); // 'tenant-value'
console.log(new Greeter().name); // 'global-value' — a bare `new` never sees a childFull API, the async-host bridge (setContainerProvider), and the
constraints above (@Vial always registers to the global; a bare new
never sees a child; resolve()'s factory lookup never reads through to the
parent) in containers.
| Module | Description | Documentation |
|---|---|---|
Doctor |
Process-wide injector (register, dispense, resolve, checkup) | This page |
inject |
Resolve by label, by class, or — untyped — by name | Doctor-Inject |
stock |
Typed labels for ready-made values and labelled factories | Doctor-Stock |
@Vial |
Class decorator — registers the class | Doctor-Vial |
createContainer / setContainerProvider
|
Child containers + async-host bridging | Doctor-Container |
./decorators |
Vial decorator (same export as root, narrower import) |
Doctor-Vial |
./errors |
DoctorError, UnregisteredVialError, ... |
Doctor-Errors |
./types |
Vial, VialModes, VialOptions, Label, StockOptions
|
— |
./examples |
The runnable order-service example | examples/ |
One runnable, multi-file app under
packages/doctor/examples/order-service/ covers
every idea on this page: typed labels stocked at boot, a factory-prescribed
vial, SINGLETON / SCOPED / TRANSIENT lifecycles, the ambient operation
scope via Doctor.resolve(Handler, scope) + discharge, a lazy getter
breaking a cycle, an optional dependency via Doctor.has, checkup(), and
a verification script that swaps fakes with revoke + prescribe.
deno run packages/doctor/examples/order-service/main.ts
bun run packages/doctor/examples/order-service/main.ts
node --import tsx packages/doctor/examples/order-service/main.ts- inject — resolve by label, by class, or untyped by name
- stock — typed labels for ready-made values and labelled factories
- @Vial — registration decorator
- containers — child containers + async-host bridging
- Errors — error classes and matching strategies
- order-service example — every idea above in one runnable app
MIT