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

@joist/di

v4.5.0

Published

Dependency Injection for Vanilla JS classes

Readme

Di

Small and efficient dependency injection.

Allows you to inject services into other class instances (including custom elements and Node.js).

Benefits

  • 🚀 Simple API: Minimal boilerplate with intuitive decorators and injection
  • 💪 Type Safety: Full TypeScript support with proper type inference
  • 🌳 Hierarchical DI: Create scoped injectors with parent-child relationships
  • ⚡️ Lazy Loading: Services are only instantiated when needed
  • 🧪 Testing Friendly: Easy mocking with provider overrides
  • 🧩 Web Component Support: Built-in integration with custom elements
  • 🔄 Context Pattern: React-like context for web components
  • 🔌 Lifecycle Hooks: Fine-grained control over service initialization
  • ⏱️ Async Support: Handle asynchronous service creation
  • 📦 Zero Dependencies: Lightweight with no external dependencies

Table of Contents

Installation

npm i @joist/di

Injectors

Injectors are the core of the dependency injection system. They:

  • Create and manage service instances
  • Handle dependency resolution
  • Maintain a hierarchy of injectors
  • Cache service instances

Services

At their simplest, services are classes. Services can be constructed via an Injector and treated as singletons (the same instance is returned for each call to Injector.inject()).

const app = new Injector();

class Counter {
  value = 0;

  inc(val: number) {
    this.value += val;
  }
}

// These two calls will return the same instance
const foo = app.inject(Counter);
const bar = app.inject(Counter);

console.log(foo === bar); // true

Injectable Services

Singleton services are great, but the real benefit can be seen when passing instances of one service to another. Services are injected into other services using the inject() function. In order to use inject(), classes must be decorated with @injectable.

inject() returns a function that will then return an instance of the requested service. This means that services are only created when they are needed and not when the class is constructed.

@injectable()
class App {
  #counter = inject(Counter);

  update(val: number) {
    const instance = this.#counter();
    instance.inc(val);
  }
}

Defining Providers

A key reason to use dependency injection is the ability to provide multiple implementations for a particular service. For example, we probably want a different HTTP client when running unit tests versus in our main application.

In the example below, we have a defined HttpService that wraps fetch. For our unit test, we'll use a custom implementation that returns just the data we want. This also has the benefit of avoiding test framework-specific mocks.

// services.ts
class HttpService {
  fetch(url: string, init?: RequestInit) {
    return fetch(url, init);
  }
}

@injectable()
class ApiService {
  #http = inject(HttpService);

  getData() {
    return this.#http()
      .fetch("/api/v1/users")
      .then((res) => res.json());
  }
}
// services.test.ts
test("should return json", async () => {
  class MockHttpService extends HttpService {
    async fetch() {
      return Response.json({ fname: "Danny", lname: "Blue" });
    }
  }

  const app = new Injector({
    providers: [[HttpService, { use: MockHttpService }]],
  });
  const api = app.inject(ApiService);

  const res = await api.getData();

  assert.equals(res.fname, "Danny");
  assert.equals(res.lname, "Blue");
});

Service Level Providers

Under the hood, each service decorated with @injectable() creates its own injector. This means that it is possible to define providers from that level down.

The example below will use this particular instance of Logger as well as any other services injected into this service.

class Logger {
  log(..._: any[]): void {}
}

class ConsoleLogger implements Logger {
  log(...args: any[]) {
    console.log(...args);
  }
}

@injectable({
  providers: [[Logger, { use: ConsoleLogger }]],
})
class MyService {}

Factories

In addition to defining providers with classes, you can also use factory functions. Factories allow for more flexibility in deciding exactly how a service is created. This is helpful when the instance that is provided depends on some runtime value.

class Logger {
  log(..._: any[]): void {}
}

const app = new Injector([
  [
    Logger,
    {
      factory() {
        const params = new URLSearchParams(window.location.search);

        if (params.has("debug")) {
          return console;
        }

        return new Logger(); // noop logger
      },
    },
  ],
]);

Accessing the Injector in Factories

Factories provide more flexibility but sometimes will require access to the injector itself. For this reason, the factory method is passed the injector that is being used to construct the requested service.

class Logger {
  log(args: any[]): void {
    console.log(...args);
  }
}

class Feature {
  #logger;

  constructor(logger: Logger) {
    this.#logger = logger;
  }
}

const app = new Injector([
  [
    Feature,
    {
      factory(i) {
        const logger = i.inject(Logger);
        return new Feature(logger);
      },
    },
  ],
]);

StaticTokens

In most cases, a token is any constructable class. There are cases where you might want to return other data types that aren't objects.

// Token that resolves to a string
const URL_TOKEN = new StaticToken<string>("app_url");

const app = new Injector([
  [
    URL_TOKEN,
    {
      factory: () => "/my-app-url/",
    },
  ],
]);

Default Values

A static token can be provided a default factory function to use on creation.

const URL_TOKEN = new StaticToken("app_url", () => "/default-url/");

Async Values

Static tokens can also leverage promises for cases when you need to asynchronously create your service instances.

// StaticToken<Promise<string>>
const URL_TOKEN = new StaticToken("app_url", async () => "/default-url/");

const app = new Injector();

const url: string = await app.inject(URL_TOKEN);

This allows you to dynamically import services:

const HttpService = new StaticToken("HTTP_SERVICE", () => {
  return import("./http.service.js").then((m) => new m.HttpService());
});

class HackerNewsService {
  #http = inject(HttpService);

  async getData() {
    const http = await this.#http();

    const url = new URL("https://hacker-news.firebaseio.com/v0/beststories.json");
    url.searchParams.set("limitToFirst", count.toString());
    url.searchParams.set("orderBy", '"$key"');

    return http.fetchJson<string[]>(url);
  }
}

LifeCycle

To help provide more information to services that are being created, Joist will call several lifecycle hooks as services are created. These hooks are defined using the provided decorators so there is no risk of naming collisions.

class MyService {
  @created()
  onCreated() {
    // Called the first time a service is created (not pulled from cache)
  }

  @injected()
  onInjected() {
    // Called every time a service is returned, whether it is from cache or not
  }
}

Conditional Lifecycle Hooks

You can control when lifecycle callbacks are executed by providing a condition function. The condition function receives the current injector:

class MyService {
  @created((injector) => ({ enabled: true }))
  onCreated() {
    // This will execute because enabled is true
  }

  @injected((injector) => {
    return {
      enabled: process.env.NODE_ENV === "development",
    };
  })
  onInjected() {
    // will only execute when NODE_ENV is development
  }
}

The condition function can return an object with an enabled property that determines whether the callback should execute:

  • { enabled: true } - The callback will execute
  • { enabled: false } - The callback will not execute
  • {} - The callback will execute (default behavior)

You can use the injector to access other services or check the injector's configuration:

class MyService {
  @created((injector) => {
    const config = injector.inject(ConfigService);
    return { enabled: config.featureEnabled };
  })
  onCreated() {
    // Only executes if feature is enabled in config
  }
}

Lifecycle conditions are useful when you need to:

  • Execute callbacks only in specific environments
  • Control callback execution based on injector state
  • Implement feature flags for lifecycle hooks
  • Conditionally initialize services based on configuration
  • Make decisions based on other services in the injector

Hierarchical Injectors

Injectors can be defined with a parent. The top-most parent will (by default) be where services are constructed and cached. Only if manually defined providers are found earlier in the chain will services be constructed lower. The injector resolution algorithm behaves as follows:

  1. Do I have a cached instance locally?
  2. Do I have a local provider definition for the token?
  3. Do I have a parent?
  4. Does parent have a local instance or provider definition?
  5. If parent exists but no instance found, create instance in parent.
  6. If no parent, all clear, go ahead and construct and cache the requested service.

Having injectors resolve this way means that all children have access to services created by their parents.

graph TD
  RootInjector --> InjectorA;
  InjectorA --> InjectorB;
  InjectorA --> InjectorC;
  InjectorA --> InjectorD;
  InjectorD --> InjectorE;

In the above tree, if InjectorE requests a service, it will navigate up to the RootInjector and cache. If InjectorB then requests the same token, it will receive the same cached instance from RootInjector.

On the other hand, if a provider is defined at InjectorD, then the service will be constructed and cached there. InjectorB would be given a NEW instance created from RootInjector. This is because InjectorB does not fall under InjectorD. This behavior allows for services to be "scoped" within a certain branch of the tree. This is what allows for the scoped custom element behavior defined in the next section.

Custom Elements

Joist is built to work with custom elements. Since the document is a tree, we can search up that tree for providers.

Setting your web page to work is very similar to any other JavaScript environment. There is a special DOMInjector class that will allow you to attach an injector to any location in the DOM, in most cases this will be document.body.

const app = new DOMInjector();

app.attach(document.body); // Anything rendered in the body will have access to this injector.

class Colors {
  primary = "red";
  secondary = "green";
}

@injectable()
class MyElement extends HTMLElement {
  #colors = inject(Colors);

  connectedCallback() {
    const { primary } = this.#colors();
    this.style.background = primary;
  }
}

customElements.define("my-element", MyElement);

Context Elements

Context elements are where Hierarchical Injectors can really shine as they allow you to define React/Preact-esque "context" elements. Since custom elements are treated the same as any other class, they can define providers for their local scope. The provideSelfAs property will provide the current class for the tokens given. This also makes it easy to use attributes to define values for the service.

class ColorCtx {
  primary = "red";
  secondary = "green";
}

@injectable({
  name: "color-ctx",
  provideSelfAs: [ColorCtx],
})
class ColorCtx extends HTMLElement implements ColorCtx {
  get primary() {
    return this.getAttribute("primary") ?? "red";
  }

  get secondary() {
    return this.getAttribute("secondary") ?? "green";
  }
}

@injectable()
class MyElement extends HTMLElement {
  #colors = inject(ColorCtx);

  connectedCallback() {
    const { primary } = this.#colors();
    this.style.background = primary;
  }
}

// Note: To use parent providers, the parent elements need to be defined first!
customElements.define("color-ctx", ColorCtx);
customElements.define("my-element", MyElement);
<!-- Default Colors -->
<my-element></my-element>

<!-- Colors come from context -->
<color-ctx primary="orange" secondary="blue">
  <my-element></my-element>
</color-ctx>