@dmytromykhailiuk/injectable
v1.0.0
Published
Dependency injection with nested containers, full TypeScript typings, and ergonomic API
Downloads
88
Maintainers
Readme
@dmytromykhailiuk/injectable
Tiny, zero-dependency DI container for TypeScript with nested scopes.
If you've used providers in Angular or NestJS and wished for the same model without the framework, this package gives you exactly that: hierarchical containers, multi-providers, and typed injection tokens — in a single file, with no decorators and no reflect-metadata.
Why this library
- Zero dependencies. One file, dual CJS/ESM build, ships only
dist/. - Hierarchical containers. Parent / child resolution with
host,skipSelf, andoptionalflags — model request scopes, test overrides, or feature modules without ceremony. - Strict TypeScript. Generic
inject<T>()/get<T>(), typed injection tokens, noanyin the public surface.
Installation
npm i @dmytromykhailiuk/injectableRequires Node ≥ 18. No reflect-metadata, no experimentalDecorators needed in the consuming project.
Quick start
import {
createContainer,
inject,
createInjectionToken,
} from "@dmytromykhailiuk/injectable";
class Logger {
log(msg: string) {
console.log(`[log] ${msg}`);
}
}
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 only valid while a provider is being instantiated. Use container.get() to pull instances out from the outside.
Providers
Anything you pass to container.register() is a provider. The shapes are:
Class provider
The simplest form — pass the class. The container will new it on first registration and cache the instance.
class Mailer {}
container.register(Mailer);
container.get(Mailer); // same instance every timeuseValue — bind a static value
const API_URL = createInjectionToken("API_URL");
container.register({
provide: API_URL,
useValue: "https://api.example.com",
});
container.get<string>(API_URL); // "https://api.example.com"useCreate — factory or class
useCreate accepts a zero-arg factory or a class. Use inject() inside the factory to pull in other providers — that's how you wire constructor arguments.
interface Logger {
// logger interface
}
const LOGGER = createInjectionToken("logger");
function loggerResolver() {
const platform = inject(PlatformFacade);
const browserLogger = inject(BrowserLogger);
const serverLogger = inject(ServerLogger);
return platform.isServer ? serverLogger : browserLogger;
}
container.register({
provide: LOGGER,
useCreate: loggerResolver,
});You can define class or function with inject argments:
function loggerResolver(
platform = inject(PlatformFacade),
browserLogger = inject(BrowserLogger),
serverLogger = inject(ServerLogger)
) {
return platform.isServer ? serverLogger : browserLogger;
}
class Group {
constructor(
private logger = inject<Logger>(LOGGER);
) {}
}useExisting — alias
useExisting resolves the target immediately and stores the same instance under the new token.
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 you get an array back.
const HOOKS = createInjectionToken("Hooks");
container.register(
{ provide: HOOKS, useValue: "before", multi: true },
{ provide: HOOKS, useValue: "after", multi: true }
);
container.get<string[]>(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 isn't a class (primitives, interfaces, abstract contracts), create a symbol-based token. Symbols are identity-based, so they never collide with strings used elsewhere.
import { createInjectionToken } from "@dmytromykhailiuk/injectable";
const CONFIG = createInjectionToken("CONFIG");
container.register({ provide: CONFIG, useValue: { retries: 3 } });
interface Config {
retries: number;
}
container.get<Config>(CONFIG).retries; // 3Plain strings work too (container.get("CONFIG")), but symbols are recommended for anything beyond a quick prototype.
inject() vs container.get()
Both resolve providers, but they're used in different places:
| | inject(token, opts?) | container.get(token, opts?) |
| -------------------- | ---------------------------------------------------------------------- | ----------------------------------------- |
| Called from | inside a class constructor or useCreate factory, during registration | application code that holds the container |
| Container | uses the active resolver implicitly | uses the container you call it on |
| Outside registration | throws "Inject must be used in scope of Injectable entity!" | always valid |
class OrderService {
private mailer = inject(Mailer); // OK — inside constructor
}
inject(Mailer); // throws — no active container
container.get(Mailer); // OKInjection 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 parent only
multi?: boolean; // treat the result as an array
optional?: boolean; // return undefined instead of throwing when missing
}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 LoggerNested containers
Pass a parent to createContainer and you get hierarchical resolution: the child checks itself first, then falls back to the parent.
const root = createContainer();
root.register({ provide: "API_URL", useValue: "https://prod.example.com" });
root.register(Logger);
const test = createContainer(root);
test.register({ provide: "API_URL", useValue: "http://localhost:3000" });
test.get("API_URL"); // "http://localhost:3000" — child wins
test.get(Logger); // inherited from rootFor multi providers the arrays merge — child values come first, then 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<string[]>(HOOKS, { multi: true }); // ["child", "root"]Deferred registration
Registration order doesn't matter. If a factory calls inject() for a token that hasn't been registered yet, the container parks the registration and re-runs it as soon as the missing token arrives.
class Db {}
class UserService {
private db = inject(Db);
}
const c = createContainer();
c.register(UserService); // Db not registered yet — parked, not thrown
c.register(Db); // triggers UserService registration automatically
c.get(UserService); // readyThis also works across parent / child boundaries: a child waits for a token a parent will register.
Lifecycle: destroy() and subscribe()
destroy() clears all instances, drops subscribers and pending registrations, and emits a container-destroyed event. Use it for per-request child containers or test teardown.
subscribe() notifies you when providers register and when the container is destroyed. The returned function unsubscribes.
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();Patterns
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 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);API reference
// Tokens & containers
createInjectionToken(id: string): symbol;
createContainer(parent?: Container): Container;
// Container
interface Container {
register(...providers: ProviderOption[]): void;
get<T>(provider: symbol | string | (new () => T), options?: InjectOptions): T;
get<T>(provider: [new () => T], options?: InjectOptions): T[];
destroy(): void;
subscribe(fn: (event: ContainerEvent) => void): () => void;
}
// 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> = symbol | string | (new () => T);
// Injection
inject<T>(provider: symbol | string | (new () => T), options?: InjectOptions): T;
inject<T>(provider: [new () => T], options?: InjectOptions): T[];
interface InjectOptions {
host?: boolean;
skipSelf?: boolean;
multi?: boolean;
optional?: boolean;
}
// Events
type ContainerEvent =
| { type: "provider-registered"; token: string | symbol }
| { type: "container-destroyed" };Errors
Inject must be used in scope of Injectable entity!—inject()was called outside aregister()factory or constructor.Provider "<token>" already registered!— same non-multi token registered twice in the same container.Provider "<token>" is not multi!— same token was registered both as multi and non-multi.
Limitations
- Synchronous resolution —
useCreatecannot return aPromise. Resolve async work upfront (or behind a lazy method) and register the result. - No decorators.
@Injectable/@Injectaren't part of this package by design. All wiring is explicit viainject(). - No built-in module system. Group providers with a plain function (see Patterns above).
License
MIT © Dmytro Mykhailiuk
