Skip to content
GitHub Actions edited this page Sep 18, 2026 · 10 revisions

Doctor

Lightweight dependency injection for Deno, Bun, Node.js, Cloudflare Workers, and browsers — no reflect-metadata, no legacy decorators, no build flags.

JSR JSR Score Deno Bun Node.js Cloudflare Workers Browsers

Overview

Doctor is a small DI container built around three primitives:

  • @Vial(mode) — class decorator that registers a class under a lifecycle (SINGLETON, SCOPED, or TRANSIENT).
  • 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 started

The 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.

Migrating from 1.0.x

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

Installation

Deno:

deno add @tundralibs/doctor

Bun:

bunx jsr add @tundralibs/doctor

Node.js:

npx jsr add @tundralibs/doctor

TypeScript configuration

@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:

// tsconfig.json — both flags absent or false
{
  "compilerOptions": {
    "experimentalDecorators": false,
    "emitDecoratorMetadata": false
  }
}
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.)

Bundling for the browser or a Worker (esbuild/Wrangler/Vite)

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.

The three injection idioms

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');
  }
}

Lazy injection

Reach for the lazy-getter idiom when you need to:

  • break a dependency cycle — two eager inject()s pointing at each other throw CircularDependencyError (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.

Lifecycles

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.

Scopes and the ambient operation scope

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 scope

Doctor.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.

Boot-time preflight: checkup()

Doctor.checkup(); // eagerly dispenses every registered SINGLETON

Constructs 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).

Vials with constructor arguments

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 { ... }

Strings: the untyped escape hatch

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.

Ready-made values — label + stock

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. await the setup, then stock the result.
  • SCOPED means an explicit scope name plus Doctor.discharge(scope) when you are done — the scope a Doctor.resolve(Handler, scope) operation carries, not an ambient per-request context.

Full API in stock.

Multi-container: createContainer + setContainerProvider

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 child

Full 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.

Modules

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/

Example

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

Related Documentation

  • 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

License

MIT

Clone this wiki locally