npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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, world

The whole surface is three functions — createContainer, inject, createInjectionToken — plus a handful of provider shapes and error classes. It reads in one sitting.


Contents


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, 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's inject().
  • 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 time

useValue — 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 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: 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 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.

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>` 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 (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 registered

Injection 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 Logger

host 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 root

For 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); // 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.


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); // boolean

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);

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. useCreate cannot return a Promise. Resolve async work upfront and register the result, or expose it behind a lazy method.
  • No decorators. @Injectable / @Inject are not part of this package by design. Dependencies are named explicitly with inject().
  • No constructor-type injection. There is no reflect-metadata, so you cannot inject from a parameter's declared type — use inject() (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 and get() returns undefined for 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 undefined from get() rather than throwing at startup. Use has() 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 + build

License

MIT © Dmytro Mykhailiuk