@dmytromykhailiuk/injectable
v1.0.1
Published
Dependency injection with nested containers, full TypeScript typings, and an ergonomic, decorator-free API. Angular / NestJS providers without the framework.
Maintainers
Readme
@dmytromykhailiuk/injectable
Full documentation: open Docs
Dependency injection, without the framework.
If you have used providers in Angular or NestJS and wished for the same mental model without the runtime around it, this is that model on its own: hierarchical containers, class / value / factory / alias providers, multi-providers, and typed injection tokens. No decorators, no reflect-metadata, no compiler flags. Zero dependencies.
import {
createContainer,
inject,
createInjectionToken,
} from "@dmytromykhailiuk/injectable";
class Logger {
log(message: string) {
console.log(`[log] ${message}`);
}
}
class UserService {
// A dependency, declared where it is used — no constructor boilerplate.
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, worldThe whole surface is three functions — createContainer, inject, createInjectionToken — plus a handful of provider shapes and error classes. It reads in one sitting.
Contents
- Install
- Why
- Quick start
- Providers
- Injection tokens
inject()vscontainer.get()- Injection options
- Nested containers
- Deferred registration
- Lifecycle
- Recipes
- Compared to Angular DI
- Compared to NestJS DI
- API reference
- Limitations
- Development
Install
npm i @dmytromykhailiuk/injectableRequires 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, and the package is side-effect-free and tree-shakeable.
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.
Why
Wiring objects together by hand does not scale. You end up threading 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 idea is the same one those frameworks use:
- A provider is a recipe for a value, registered under a key. The key is a class, a string, or a typed token.
inject()asks the container for a key while a provider is being constructed. It takes no container argument — it resolves against whichever container is doing the building right now, exactly like Angular'sinject().- Containers nest. A child resolves from itself first, then falls back to its parent. That single rule gives you request scopes, feature modules, and test overrides for free.
What you give up by not using a framework is decorators and reflection-based auto-wiring. What you get back is a container with no magic: providers are plain objects, resolution is a Map lookup up a parent chain, and the whole thing has no dependencies and one job.
What it 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.
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);
container.get(UserService).greet("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 timeuseValue — bind 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 stringuseCreate — 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); // truemulti: true — 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:
class Middleware {}
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 factoryInjection 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.
import { createInjectionToken } from "@dmytromykhailiuk/injectable";
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>` neededThe 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 (container.get("CONFIG")), 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 registeredInjection options
Both inject() and container.get() accept the same options:
interface InjectOptions {
host?: boolean; // resolve only from this container, ignore parents
skipSelf?: boolean; // skip this container, resolve from the parent chain
multi?: boolean; // treat the result as an array
optional?: boolean; // return undefined instead of throwing when missing
}// 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 Loggerhost and skipSelf are the same flags Angular exposes, with the same meaning — see Compared to Angular DI.
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 rootFor multi providers the arrays merge — the child's values come first, then the parent's:
const root = createContainer();
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); // readyThis 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.
Lifecycle
destroy()
Clears every instance, drops subscribers and pending registrations, detaches from the parent, and emits a container-destroyed event. Use it for per-request child scopes and test teardown.
subscribe()
Notifies you when a provider registers and when the container is destroyed. The returned function unsubscribes. Registrations that happen in a parent propagate to a child's subscribers.
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();has()
Returns whether a token resolves in this container or any ancestor.
container.has(Logger); // booleanRecipes
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);Pick an implementation at build time
const STORAGE = createInjectionToken<Storage>("STORAGE");
container.register(MemoryStorage, RedisStorage, {
provide: STORAGE,
useCreate: () =>
inject(Config).env === "test" ? inject(MemoryStorage) : inject(RedisStorage),
});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.
Angular
import { Injectable, InjectionToken, Injector, inject } from "@angular/core";
const API_URL = new InjectionToken<string>("API_URL");
@Injectable()
class Logger {
log(msg: string) {}
}
@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);This library
import { createContainer, createInjectionToken, inject } from "@dmytromykhailiuk/injectable";
const API_URL = createInjectionToken<string>("API_URL");
class Logger {
log(msg: string) {}
}
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 } or the class | the class, or { provide, useCreate: Class } |
| Multi | { provide, useValue, multi: true } | { provide, useValue, multi: true } — identical |
| Hierarchical injectors | parent/child injectors | createContainer(parent) |
| Resolution modifiers | @Host / @SkipSelf / @Optional | { host } / { skipSelf } / { optional } |
| Decorators / reflect-metadata | required | none |
| Constructor parameter injection | constructor(private x: Dep) + metadata | inject() — no metadata, no deps array |
The one thing Angular does that this cannot is inject through constructor parameter types (constructor(private dep: Dep)), 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.
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 — useValue, useClass/useCreate, useFactory/useCreate, useExisting, custom tokens, multi-providers — but replaces the module graph with plain container objects and the decorators with explicit inject().
NestJS
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 {}This library
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 a string / symbol) |
| Module system | @Module({ providers, imports, exports }) | a plain function that calls register() |
| Request scope | Scope.REQUEST + framework 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 (see Deferred registration) — which is more forgiving, but means a genuinely missing provider surfaces as undefined from get() rather than as a startup error.
API reference
// Tokens & containers
createInjectionToken<T = unknown>(id: string): InjectionToken<T>;
createContainer(parent?: Container): Container;
interface Container {
register(...providers: ProviderOption[]): void;
get<T>(token: InjectionToken<T> | (new () => T), options?: InjectOptions): T;
get<T>(token: [new () => T], options?: InjectOptions): T[];
get<T = unknown>(token: string, options?: InjectOptions): T;
has(token: Provider | [ProviderClass]): boolean;
destroy(): void;
subscribe(fn: (event: ContainerEvent) => void): () => void;
}
// 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 } & InjectOptions): T | undefined;
interface InjectOptions {
host?: boolean;
skipSelf?: boolean;
multi?: boolean;
optional?: boolean;
}
// Provider shapes
type ProviderOption<T = unknown> =
| (new () => T)
| { provide: Provider; useValue: T; multi?: boolean }
| { provide: Provider; useExisting: Provider<T>; multi?: boolean }
| { provide: Provider; useCreate: (new () => T) | (() => T); multi?: boolean };
type Provider<T = unknown> = InjectionToken<T> | string | (new () => T);
// Events
type ContainerEvent =
| { type: "provider-registered"; token: string | symbol }
| { type: "container-destroyed" };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
- Synchronous only.
useCreatecannot return aPromise. Resolve async work upfront and register the result, or expose it behind a lazy method. - No decorators.
@Injectable/@Injectare not part of this package by design. Dependencies are named explicitly withinject(). - No constructor-type injection. There is no
reflect-metadata, so you cannot inject from a parameter's declared type — useinject()(a default parameter is the closest form). - No module system. Group providers with a plain function (see Recipes).
- Circular dependencies do not resolve. If two providers each
inject()the other, both park forever andget()returnsundefinedfor both — there is no cycle-detection error. Break the cycle with a factory that resolves one side lazily. - A genuinely missing dependency is silent. Because resolution is deferred, an unmet dependency reads as
undefinedfromget()rather than throwing at startup. Usehas()if you need to assert presence.
Development
npm run playground # a runnable tour of every feature
npm run playground:watch # the same, re-running on save
npm test # the full runtime test suite
npm run test:coverage # tests with a coverage report
npm run typecheck # tsc --noEmit
npm run lint # biome
npm run verify # lint + typecheck + test + buildLicense
MIT © Dmytro Mykhailiuk
