injectable
Dependency injection for TypeScript, on its own. Hierarchical containers, class / value / factory / alias providers, multi-providers, and typed injection tokens — the model you know from Angular and NestJS, with no framework around it.
No decorators, no reflect-metadata, no compiler flags.
inject() resolves against whichever container is
building right now, containers nest so a child falls back to its
parent, and resolution is a Map lookup up a chain.
Nothing is magic.
Getting started
Install#
npm i @dmytromykhailiuk/injectable
Requires Node 18+ and TypeScript
5.0+. No reflect-metadata, no
experimentalDecorators, nothing to enable in the
consuming project. ESM and CJS builds ship side by side with
separate .d.ts / .d.cts declarations; the
package is side-effect-free.
Three functions, and that is all
import {
createContainer, // makes a container (optionally nested under a parent)
inject, // pulls a dependency while a provider is being built
createInjectionToken, // a typed key for anything that is not a class
} from "@dmytromykhailiuk/injectable";
Everything else is a provider object you pass to
register(), an option you pass to
inject() / get(), or an error class you
can catch.
Motivation
The idea#
Wiring objects by hand does not scale. You thread the same
Logger through six constructors to reach the one class
that needs it, and every new dependency edits every call site on the
way down. Frameworks solve this with a container — but adopting
Angular or NestJS to get one is a lot of framework for one idea.
This library is just the container. The model is the one those frameworks use:
The key is a class, a string, or a typed token; the recipe is
a class to new, a value to hand back, a factory
to call, or an alias to another key.
It resolves against whichever container is doing the building
right now — exactly like Angular's inject(). That
is what lets a dependency be declared where it is used.
One rule — a child resolves from itself, then falls back to its parent — gives you request scopes, feature modules, and test overrides for free.
What it deliberately is not#
No decorators, no reflect-metadata, no module system,
no lifecycle hooks beyond destroy, no async providers,
no proxies. If you want those,
Angular and
NestJS are excellent
and this is not trying to replace them — this is the resolution
model underneath, on its own.
The vocabulary is borrowed openly. provide /
useValue / useExisting /
multi, hierarchical injectors, and the
host / skipSelf /
optional modifiers all mean what they mean in
Angular. The contribution here is doing it in a few hundred lines
with no dependencies.
Quick start#
import { createContainer, inject } from "@dmytromykhailiuk/injectable";
class Logger {
log(message: string) {
console.log(`[log] ${message}`);
}
}
class UserService {
private logger = inject(Logger);
greet(name: string) {
this.logger.log(`hello, ${name}`);
}
}
const container = createContainer();
container.register(UserService, Logger); // order does not matter
container.get(UserService).greet("world"); // [log] hello, world
inject() is valid
only while a provider is being instantiated —
inside a class constructor (a field initializer counts) or a
useCreate
factory. To pull an instance out from application code that holds
the container, use
container.get().
Providers#
Anything you pass to container.register() is a
provider. Every provider is instantiated once, on
registration, and the instance is cached — providers are singletons
within their container.
Class provider#
The simplest form. Pass the class; the container news
it and caches the result.
class Mailer {}
container.register(Mailer);
container.get(Mailer); // the same instance every time
useValue — a ready-made value#
const API_URL = createInjectionToken<string>("API_URL");
container.register({ provide: API_URL, useValue: "https://api.example.com" });
container.get(API_URL); // "https://api.example.com", typed as string
useCreate — factory or class#
useCreate takes a zero-argument factory
or a class. Call inject() inside it to
pull in other providers — that is how you wire what would otherwise
be constructor arguments.
const LOGGER = createInjectionToken<Logger>("LOGGER");
container.register({
provide: LOGGER,
useCreate: () => {
const platform = inject(PlatformFacade);
return platform.isServer ? inject(ServerLogger) : inject(BrowserLogger);
},
});
Injecting through default parameters works too, which reads nicely for classes:
class Group {
constructor(private logger = inject<Logger>(LOGGER)) {}
}
useExisting — alias#
Resolve another token now and store the same instance under a new key.
container.register(Logger);
container.register({ provide: "AppLogger", useExisting: Logger });
container.get("AppLogger") === container.get(Logger); // true
multi — collect into an array#
Register the same token several times with
multi: true and read the values back as an array.
const HOOKS = createInjectionToken<string>("HOOKS");
container.register(
{ provide: HOOKS, useValue: "before", multi: true },
{ provide: HOOKS, useValue: "after", multi: true },
);
container.get(HOOKS, { multi: true }); // ["before", "after"]
For class-based multi providers there is an array sugar —
[Class] — that both inject() and
get() accept:
container.register({ provide: Middleware, useCreate: Cors, multi: true });
container.register({ provide: Middleware, useCreate: Auth, multi: true });
container.get([Middleware]); // Middleware[]
inject([Middleware]); // same, inside a factory
Injection tokens#
For anything that is not a class — primitives, interfaces, abstract
contracts — create a token.
createInjectionToken<T>() returns a real
symbol, so it can never collide with a string used
elsewhere, and it carries T at the type level, so
resolution infers the value type with no second generic.
interface Config {
retries: number;
}
const CONFIG = createInjectionToken<Config>("CONFIG");
container.register({ provide: CONFIG, useValue: { retries: 3 } });
container.get(CONFIG).retries; // 3 — typed, no `get<Config>` needed
The id is a label for debugging only. Two tokens made
with the same id are still distinct — symbols are
compared by identity. Plain strings work as keys too, but a token
is safer for anything beyond a quick prototype.
inject() vs container.get()#
Both resolve providers; they differ in where they are used.
inject(token, opts?) |
container.get(token, opts?) |
|
|---|---|---|
| Called from |
a constructor or useCreate factory, during
registration
|
application code holding the container |
| Which container | the one currently building — resolved implicitly | the one you call it on |
| Outside registration |
throws InjectOutOfContextError
|
always valid |
| Missing & not optional | throws — parks the provider being built | returns undefined |
class OrderService {
private mailer = inject(Mailer); // OK — inside a constructor
}
inject(Mailer); // throws — no active container
container.get(Mailer); // OK — undefined if not registered
Injection options#
Both inject() and container.get() accept
the same options.
Resolve only from this container, ignoring parents.
Skip this container, resolve from the parent chain.
Treat the result as an array of every registration.
Return undefined instead of throwing when
missing. The return type widens to include
undefined.
// With { optional: true } the return type widens to include undefined:
const analytics = inject(Analytics, { optional: true }) ?? new NoopAnalytics();
container.get(Logger, { skipSelf: true }); // explicitly use the parent's Logger
container.get(Logger, { host: true }); // only this container's Logger
host and skipSelf are the same flags
Angular exposes as @Host() and
@SkipSelf(), with the same meaning.
Nested containers#
Pass a parent to createContainer and resolution becomes
hierarchical: a child checks itself first, then falls back to the
parent chain.
const root = createContainer();
root.register({ provide: "API_URL", useValue: "https://prod.example.com" });
root.register(Logger);
const scope = createContainer(root);
scope.register({ provide: "API_URL", useValue: "http://localhost:3000" });
scope.get("API_URL"); // "http://localhost:3000" — child wins
scope.get(Logger); // inherited from root
For multi providers the arrays merge — the child's
values first, then the parent's:
root.register({ provide: HOOKS, useValue: "root", multi: true });
const child = createContainer(root);
child.register({ provide: HOOKS, useValue: "child", multi: true });
child.get(HOOKS, { multi: true }); // ["child", "root"]
Deferred registration#
Registration order does not matter. If a provider being built calls
inject() for a token that has not been registered yet,
the container parks that registration and re-runs
it automatically as soon as the missing token arrives.
class Db {}
class UserService {
private db = inject(Db);
}
const c = createContainer();
c.register(UserService); // Db missing — parked, not thrown
c.register(Db); // arrival of Db re-runs UserService
c.get(UserService); // ready
This also works across the parent/child boundary — a child waits for
a token its parent will register later. It is why
register(UserService, Logger) resolves even though the
dependent is listed before its dependency.
Deferral is forgiving, but it means a genuinely missing dependency
surfaces as
undefined from get() rather than as a
startup error. And two providers that each
inject() the other both park forever — there is no
cycle-detection error. See Limitations.
Lifecycle#
Clears every instance, drops subscribers and pending
registrations, detaches from the parent, and emits
container-destroyed. For per-request scopes and
test teardown.
Notifies you on each registration and on destruction; parent registrations propagate to a child's subscribers. Returns an unsubscribe function.
Whether a token resolves in this container or any ancestor.
const container = createContainer();
const unsubscribe = container.subscribe((event) => {
if (event.type === "provider-registered") {
console.log("registered:", event.token.toString());
} else {
console.log("container destroyed");
}
});
container.register(Logger);
unsubscribe();
container.destroy();
Recipes#
Per-request scope#
function handleRequest(req: Request) {
const scope = createContainer(rootContainer);
scope.register({ provide: "REQ", useValue: req });
try {
return scope.get(RequestHandler).run();
} finally {
scope.destroy();
}
}
Swap a real service for a fake in tests#
const test = createContainer(appContainer);
test.register({ provide: Mailer, useValue: new FakeMailer() });
expect(test.get(OrderService).checkout()).toMatchSnapshot();
Group registrations as a "module"#
export function registerAuthModule(c: Container) {
c.register(PasswordHasher, TokenIssuer, {
provide: AuthService,
useCreate: () => new AuthService(inject(TokenIssuer), inject(PasswordHasher)),
});
}
registerAuthModule(container);
Context
Compared to Angular DI#
The resolution model is deliberately the same as Angular's —
inject(), hierarchical injectors, multi-providers, and
the host / skipSelf /
optional flags all mean what they mean in Angular. The
difference is that there is no framework, no NgModule,
no decorators, and no compiler step.
import { Injectable, InjectionToken, Injector, inject } from "@angular/core";
const API_URL = new InjectionToken<string>("API_URL");
@Injectable()
class Logger {}
@Injectable()
class UserService {
private logger = inject(Logger);
private apiUrl = inject(API_URL);
}
const injector = Injector.create({
providers: [
Logger,
UserService,
{ provide: API_URL, useValue: "https://api.example.com" },
],
});
injector.get(UserService);
import { createContainer, createInjectionToken, inject } from "@dmytromykhailiuk/injectable";
const API_URL = createInjectionToken<string>("API_URL");
class Logger {}
class UserService {
private logger = inject(Logger);
private apiUrl = inject(API_URL);
}
const container = createContainer();
container.register(Logger, UserService, {
provide: API_URL,
useValue: "https://api.example.com",
});
container.get(UserService);
| Concept | Angular | This library |
|---|---|---|
| Field injection | inject(Dep) |
inject(Dep) — identical |
| Token | new InjectionToken<T>("x") |
createInjectionToken<T>("x") |
| Value provider | { provide, useValue } |
{ provide, useValue } — identical |
| Factory provider | { provide, useFactory, deps } |
{ provide, useCreate }, deps via
inject()
|
| Alias provider | { provide, useExisting } |
{ provide, useExisting } — identical |
| Class provider | { provide, useClass } |
the class, or { provide, useCreate: Class }
|
| Multi | multi: true |
multi: true — identical |
| Hierarchy | parent/child injectors | createContainer(parent) |
| Modifiers |
@Host / @SkipSelf /
@Optional
|
{ host } / { skipSelf } /
{ optional }
|
| Decorators / metadata | required | none |
| Constructor-type injection | constructor(private x: Dep) |
inject() — no metadata, no
deps array
|
The one thing Angular does that this cannot is inject through
constructor parameter types, because that relies on
emitDecoratorMetadata. Here dependencies are named
explicitly with inject() — a default parameter (constructor(private dep = inject(Dep))) is the closest equivalent, and it needs no build step.
Context
Compared to NestJS DI#
NestJS wires dependencies through constructor parameter types,
@Injectable() decorators,
reflect-metadata, and a module graph. This library
keeps the same provider vocabulary but replaces the module graph
with plain container objects and the decorators with explicit
inject().
import { Injectable, Inject, Module } from "@nestjs/common";
const API_URL = "API_URL";
@Injectable()
class Logger {}
@Injectable()
class UserService {
constructor(
private readonly logger: Logger,
@Inject(API_URL) private readonly apiUrl: string,
) {}
}
@Module({
providers: [
Logger,
UserService,
{ provide: API_URL, useValue: "https://api.example.com" },
],
})
class AppModule {}
import { createContainer, inject } from "@dmytromykhailiuk/injectable";
const API_URL = "API_URL";
class Logger {}
class UserService {
private logger = inject(Logger);
private apiUrl = inject<string>(API_URL);
}
const container = createContainer();
container.register(Logger, UserService, {
provide: API_URL,
useValue: "https://api.example.com",
});
| Concept | NestJS | This library |
|---|---|---|
| Marking a class | @Injectable() |
nothing — any class is a provider |
| Injecting a class | constructor param + metadata | inject(Dep) |
| Injecting a token | @Inject(TOKEN) x |
inject(TOKEN) |
| Value provider | { provide, useValue } |
{ provide, useValue } — identical |
| Class provider | { provide, useClass } |
{ provide, useCreate: Class } |
| Factory provider | { provide, useFactory, inject } |
{ provide, useCreate }, deps via
inject()
|
| Alias provider | { provide, useExisting } |
{ provide, useExisting } — identical |
| Custom token | a string or Symbol |
createInjectionToken() (or string / symbol)
|
| Module system | @Module({ providers, imports }) |
a plain function that calls register() |
| Request scope | Scope.REQUEST + machinery |
createContainer(parent) per request |
| Runtime dependencies | reflect-metadata |
none |
NestJS resolves the entire module graph at bootstrap and throws if
anything is unresolvable. This library resolves lazily and
parks an unmet dependency until it is registered —
more forgiving, but a genuinely missing provider surfaces as
undefined from get() rather than as a
startup error.
API reference#
createInjectionToken<T = unknown>(id: string): InjectionToken<T>;
createContainer(parent?: Container): Container;
// Injection — same call shape as get()
inject<T>(token: InjectionToken<T> | (new () => T), options?: InjectOptions): T;
inject<T>(token: [new () => T], options?: InjectOptions): T[];
inject<T>(token: Token, options: { optional: true }): T | undefined;
interface InjectOptions {
host?: boolean;
skipSelf?: boolean;
multi?: boolean;
optional?: boolean;
}
Container#
Instantiate and cache one or more providers. Order-independent — an unmet dependency parks until it arrives.
Resolve a provider. Never throws for a missing token — reads
as undefined (or [] for multi).
Whether a token resolves here or in any ancestor.
Clear instances, drop subscribers and pending work, detach
from the parent, emit container-destroyed.
Observe provider-registered and
container-destroyed events. Returns an
unsubscribe function.
Errors#
All are exported and can be caught with instanceof.
| Class | Thrown when |
|---|---|
InjectOutOfContextError |
inject() is called outside a
register() factory or constructor.
|
ProviderAlreadyRegisteredError |
the same non-multi token is registered twice in one container. |
MultiProviderConflictError |
a token is registered — or read — both as multi and non-multi. |
EmptyTokenError |
a provider is registered under an empty string token. |
MissingProviderError |
a required inject() cannot resolve (parks the
provider being built).
|
Limitations#
useCreate cannot return a Promise.
Resolve async work upfront and register the result, or expose
it behind a lazy method.
@Injectable / @Inject are not part
of this package. Dependencies are named explicitly with
inject(); there is no constructor-type injection
because there is no reflect-metadata.
If two providers each inject() the other, both
park and get() returns undefined for
both — there is no cycle-detection error. Break the cycle with
a factory that resolves one side lazily.
Because resolution is deferred, an unmet dependency reads as
undefined from get() rather than
throwing at startup. Use has() to assert
presence.