-
Notifications
You must be signed in to change notification settings - Fork 2
Doctor Inoculate
Class decorator that wraps the constructor so every new call
automatically treats the new instance.
@Inoculate(scope?: string)
class MyHandler { ... }The wrapped constructor:
- Calls the original constructor (forwarding arguments).
- Calls
Doctor.treat(instance, scope)to fill@Doseproperties — on a directnew WrappedClass()and on a plain subclass (at any depth) that adds no@Doseof its own, but never mid-super()while any more-derived level is still initializing its own@Dosefields (see Subclassing). - Returns the treated instance.
The decorator preserves instanceof, static members, and
constructor.name.
The scope argument is captured at decoration time and reused
for every new call on this class. For per-instance scope (typical
web-request handlers), use Doctor.resolve instead — it constructs
the class with a caller-supplied scope and treats it once, without a
second decoration-time treat: on a directly wrapped class it unwraps
the wrapper, and on a subclass of a wrapped base it suppresses the
wrapper's auto-treat for that exact construction only — an unrelated
new SomeInoculated() performed meanwhile (say, a collaborator built
inside a @Vial factory) still auto-treats normally (see
Subclassing below).
// Decoration-time default scope:
@Inoculate('background-job')
class JobRunner {
@Dose()
public db!: Database;
}
new JobRunner(); // → injected under 'background-job'
// Per-call scope:
class RequestHandler {
@Dose()
public db!: Database;
}
const h = Doctor.resolve(RequestHandler, `req-${reqId}`);A registered @Vial factory is Doctor's
construction mechanism for that vial. When the factory body does
new SomeInoculated() and returns that instance, the wrapper's
auto-treat and the driving dispense/resolve do not both fire — the
instance is treated exactly once:
- The class's own decoration scope wins when it has one; otherwise the
operation's scope (the one passed to
dispense/resolve) fills in, so a factory returning an@Inoculate()instance whose@Doseis SCOPED resolves under the caller's scope rather than throwingScopeRequiredError. - A dependency is built once — no orphaned first copy of a TRANSIENT
@Dose.
The operation-scope fallback is applied only to the value the factory
returns, and it is applied after the factory returns. A return value
whose SCOPED @Dose needs the fallback therefore has those fields filled
in only once the factory has returned — not while the factory body is
still running.
@Vial('SCOPED')
class Db {/* ... */}
@Inoculate() // no decoration scope — the operation scope fills in
class Repo {
@Dose()
public db!: Db; // SCOPED
}
@Vial({ mode: 'SINGLETON', factory: () => new Repo() })
class RepoProvider {}
Doctor.dispense(RepoProvider, 'req-1'); // Repo.db resolved under 'req-1', treated onceA collaborator the factory builds but does not return (e.g.
() => ({ repo: new Repo() }), or a helper newed for its side effects)
is treated on its own new under its own decoration scope only — it
never inherits the operation scope. It behaves exactly as it would
outside a factory:
- With a resolvable
@Dose(SINGLETON/TRANSIENT, or a SCOPED dep plus its own decoration scope), it is injected onnew, ready to use in the factory body. - With a SCOPED
@Doseand no decoration scope of its own, it throwsScopeRequiredError— it is not the returned value, so the operation-scope fallback is not its to take. Give such a collaborator its own@Inoculate('scope'), or return it from the factory, if it genuinely needs a scope.
@Inoculate() // no decoration scope
class Repo {
@Dose()
public db!: Db; // SCOPED
}
// Repo is a NON-returned collaborator here → it does NOT get 'req-1';
// this throws ScopeRequiredError, exactly as a bare `new Repo()` would.
@Vial({ mode: 'SINGLETON', factory: () => ({ repo: new Repo() }) })
class RepoHolderProvider {}
Doctor.dispense(RepoHolderProvider, 'req-1'); // throws ScopeRequiredErrorHow a subclass of an @Inoculated base is injected depends on whether
any level more derived than the base adds its own @Dose fields.
The base wrapper runs as super(), so it fires before every subclass
field initializer at every level — the rule is the same whether the
extra @Dose sits on a direct subclass or on an intermediate class in a
deeper chain.
A plain subclass that adds no @Dose of its own — at any depth
(Leaf extends Mid extends BaseHandler, where every level below the base
is plain) — is auto-injected on new. The base's @Dose
fields are set inside super() and no later field initializer
overwrites them, so the base wrapper treats the instance — with the
base's decoration-time scope, which the subclass therefore inherits
automatically:
@Inoculate('background-job')
class BaseHandler {
@Dose()
public db!: Database; // SCOPED
}
class ReportHandler extends BaseHandler {} // no @Dose, no @Inoculate
new ReportHandler(); // → db injected under 'background-job'A class that adds its own @Dose fields must carry its own
@Inoculate at that level. Those fields initialize after super(),
so treating them in the base wrapper (which runs as super()) would
fill each and then have the field initializer immediately re-define it
to undefined — a silent half-injection. Its own wrapper treats once,
after every field (base and subclass) has initialized. Because that
wrapper captures its own scope argument, repeat the base's scope
on it — a bare @Inoculate() treats with no scope, so a SCOPED base
dependency throws
ScopeRequiredError:
@Inoculate('background-job') // repeat the base's scope — its own wrapper
class ReportHandler extends BaseHandler {
@Dose()
public reports!: ReportService;
}
new ReportHandler(); // → both db and reports injected under 'background-job'A class with its own @Dose but no @Inoculate is not
auto-injected by new — it is all-or-nothing (never a silently partial
instance), so a missing decorator is obvious rather than a half-built
object. This holds at any depth: if an intermediate class adds
@Dose without its own @Inoculate, new Leaf() on a plain leaf below
it injects nothing — inject the whole chain through Doctor.resolve, or
add @Inoculate to the level that declares the extra @Dose:
@Inoculate()
class Base {
@Dose()
a!: DepA;
}
class Mid extends Base {
@Dose()
b!: DepB; // own @Dose, but no @Inoculate — misuse
}
class Leaf extends Mid {}
new Leaf(); // → all-or-nothing: neither a nor b injected
Doctor.resolve(Leaf); // → both a and b injected (treats after construction)A well-formed multi-level chain carries @Inoculate at every level that
adds @Dose; only the most-derived wrapper treats, once, after every
field has initialized, so each level's dependency is injected exactly
once.
Per-call scope — when the scope varies per instance, skip the
subclass decorator and construct through Doctor.resolve (or
Doctor.dispense for a registered vial), which treat after
construction completes and take a caller-supplied scope:
class RequestHandler extends BaseHandler {}
const h = Doctor.resolve(RequestHandler, `req-${reqId}`);
// → db injected under `req-${reqId}`@Inoculate('request-1')
class UserHandler {
@Dose()
public db!: Database;
@Dose()
public logger!: Logger;
}
const handler = new UserHandler();
handler.db.query('SELECT 1');At new time, propagated from Doctor.treat:
-
UnregisteredVialError— required@Dosedependency has no registered vial. -
ScopeRequiredError— a required@Dosedependency is SCOPED but the decorator was invoked as@Inoculate()(no default scope).
The cli-tool example uses @Inoculate()
on every command — see
HelloCommand.ts
and StatsCommand.ts.
Each command runs with plain new HelloCommand(), and the wrapper
fills in the @Dose properties via singletons. No scope is needed
because every dependency is a SINGLETON.
The web-app example deliberately avoids
@Inoculate on its UserHandler and uses
Doctor.resolve(UserHandler, scopeName) from
main.ts instead — that's the right
call when each request needs its own scope name.
Rule of thumb:
-
Scope fixed (or absent)? Use
@Inoculate(scope?)andnew. -
Scope varies per call? Use
Doctor.resolve(Class, scope).