injectable
injectable 1.0.1

Dependency injection with nested containers. Zero dependencies, no decorators.

Contents

Contents

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.

zero dependencies no decorators hierarchical containers typed tokens three functions ~99% test coverage

Getting started

Install#

sh
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

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

A provider is a reciperegistered under a key

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.

inject() asks the active containerno container argument

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.

Containers nestchild first, then parent

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.

Note

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#

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

ts
class Mailer {}

container.register(Mailer);
container.get(Mailer); // the same instance every time

useValue — a ready-made value#

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

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

ts
class Group {
  constructor(private logger = inject<Logger>(LOGGER)) {}
}

useExisting — alias#

Resolve another token now and store the same instance under a new key.

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

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

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

ts
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
Note

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

hostboolean

Resolve only from this container, ignoring parents.

skipSelfboolean

Skip this container, resolve from the parent chain.

multiboolean

Treat the result as an array of every registration.

optionalboolean

Return undefined instead of throwing when missing. The return type widens to include undefined.

ts
// 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
Note

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.

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

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

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

Trade-off

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#

destroy()(): void

Clears every instance, drops subscribers and pending registrations, detaches from the parent, and emits container-destroyed. For per-request scopes and test teardown.

subscribe(fn)(event) => () => void

Notifies you on each registration and on destruction; parent registrations propagate to a child's subscribers. Returns an unsubscribe function.

has(token)(): boolean

Whether a token resolves in this container or any ancestor.

ts
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#

ts
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#

ts
const test = createContainer(appContainer);
test.register({ provide: Mailer, useValue: new FakeMailer() });

expect(test.get(OrderService).checkout()).toMatchSnapshot();

Group registrations as a "module"#

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

tsAngular
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);
tsThis library
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().

tsNestJS
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 {}
tsThis 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 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#

ts
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#

register(...providers)(...ProviderOption[]): void

Instantiate and cache one or more providers. Order-independent — an unmet dependency parks until it arrives.

get(token, options?)resolves, or undefined

Resolve a provider. Never throws for a missing token — reads as undefined (or [] for multi).

has(token)(): boolean

Whether a token resolves here or in any ancestor.

destroy()(): void

Clear instances, drop subscribers and pending work, detach from the parent, emit container-destroyed.

subscribe(fn)(): () => void

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#

Synchronous onlyno async providers

useCreate cannot return a Promise. Resolve async work upfront and register the result, or expose it behind a lazy method.

No decoratorsby design

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

Circular dependenciespark forever

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.

Missing deps are silentuse has()

Because resolution is deferred, an unmet dependency reads as undefined from get() rather than throwing at startup. Use has() to assert presence.