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

@lazy-di/core

v1.0.3

Published

Zero-ceremony dependency injection for TypeScript

Readme

npm version npm downloads CI codecov License: MIT

Zero-ceremony dependency injection for TypeScript.

No manual bindings. No string tokens. Just decorate your classes and resolve.

@Injectable()
class Database {
  query(sql: string) { ... }
}

@Injectable()
class UserRepository {
  constructor(private db: Database) {}
}

const container = Container.create();
const repo = container.get(UserRepository); // Database injected automatically

Why lazy-di

Most TypeScript DI containers require a central binding file. In InversifyJS it looks like this:

// container.ts — the file everyone touches in every PR
const TYPES = {
  Database: Symbol("Database"),
  UserRepository: Symbol("UserRepository"),
  OrderRepository: Symbol("OrderRepository"),
  PaymentGateway: Symbol("PaymentGateway"),
  NotificationService: Symbol("NotificationService"),
  OrderService: Symbol("OrderService"),
  App: Symbol("App"),
};

const container = new Container();
container.bind<Database>(TYPES.Database).to(Database).inSingletonScope();
container.bind<UserRepository>(TYPES.UserRepository).to(UserRepository);
container.bind<OrderRepository>(TYPES.OrderRepository).to(OrderRepository);
container.bind<PaymentGateway>(TYPES.PaymentGateway).to(StripeGateway);
container
  .bind<NotificationService>(TYPES.NotificationService)
  .to(NotificationService);
container.bind<OrderService>(TYPES.OrderService).to(OrderService);
container.bind<App>(TYPES.App).to(App);

Every new class means a new Symbol and a new bind() line. As the app grows, this file becomes a permanent source of merge conflicts.

With lazy-di, this file doesn't exist. The equivalent setup is just your entry point:

// main.ts — the only wiring you write
import "reflect-metadata";
import { Container } from "@lazy-di/core";

const container = Container.create();
const app = container.get(App);
app.run();

Dependencies are registered through decorators on the classes themselves. The container resolves them automatically.

| | lazy-di | InversifyJS | tsyringe | TypeDI | | --------------------- | ------- | ----------- | -------- | ------ | | Manual binding file | ✗ | ✓ | ✓ | ✓ | | String/Symbol tokens | ✗ | ✓ | ✓ | ✓ | | Abstract class tokens | ✓ | ✗ | ✗ | ✗ | | Conditional binding | ✓ | ✗ | ✗ | ✗ | | Collection injection | ✓ | partial | ✗ | ✗ | | Child containers | ✓ | ✓ | ✗ | ✗ |


Installation

npm install @lazy-di/core reflect-metadata

reflect-metadata is a peer dependency. It must be imported once, as the first line of your application entry point:

// main.ts — must be first
import "reflect-metadata";
import { Container } from "@lazy-di/core";

tsconfig requirements

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

Both options are required. Without emitDecoratorMetadata, TypeScript does not emit constructor parameter type information at runtime, and injection cannot work.


Getting started

Here is a minimal but realistic app wiring a repository and a service together.

// database.ts
@Injectable({ scope: "singleton" })
class Database {
  query(sql: string, params?: unknown[]) {
    // ...
  }
}
// user-repository.ts
@Injectable()
class UserRepository {
  constructor(private db: Database) {}

  findById(id: string) {
    return this.db.query("SELECT * FROM users WHERE id = ?", [id]);
  }
}
// user-service.ts
@Injectable()
class UserService {
  constructor(private users: UserRepository) {}

  getUser(id: string) {
    return this.users.findById(id);
  }
}
// main.ts — the entire wiring
import "reflect-metadata";
import { Container } from "@lazy-di/core";

const container = Container.create();
const userService = container.get(UserService);
// Database and UserRepository are resolved and injected automatically

Database is singleton — one instance is shared across every class that depends on it. UserRepository and UserService are transient — a fresh instance is created each time they are resolved.


Core concepts

@Injectable()

Marks a class as resolvable by the container. The container reads constructor parameter types at runtime and resolves them recursively.

@Injectable()
class MailService {
  send(to: string, subject: string) { ... }
}

@Injectable()
class NotificationService {
  constructor(private mail: MailService) {}

  notify(user: User) {
    this.mail.send(user.email, 'You have a new notification');
  }
}

Classes without @Injectable() cannot be resolved — the container throws a descriptive error if you attempt to resolve one.

Scopes

lazy-di supports two scopes:

  • transient (default) — a new instance is created on every container.get() call
  • singleton — one instance is created per root container and reused for all subsequent calls
@Injectable({ scope: "singleton" })
class DatabaseConnection {
  constructor() {
    // expensive setup — only runs once
  }
}

@Injectable() // transient by default
class RequestHandler {
  constructor(private db: DatabaseConnection) {}
}

You can also set the default scope at the container level:

const container = Container.create({ defaultScope: "singleton" });

Class-level scope always takes precedence over the container default.


Abstract classes as tokens

Interfaces are erased at runtime — they cannot be used as injection tokens. lazy-di solves this with abstract classes decorated with @Abstract(), which provides both a compile-time type contract and a runtime identity.

@Abstract()
abstract class PaymentGateway {
  abstract charge(amount: number, currency: string): Promise<void>;
}

@Injectable()
@Implements(PaymentGateway)
class StripeGateway extends PaymentGateway {
  async charge(amount: number, currency: string) {
    // Stripe implementation
  }
}

@Injectable()
class OrderService {
  constructor(private gateway: PaymentGateway) {}
  //                           ^ resolves to StripeGateway automatically
}

@Abstract() marks the abstract class as a valid token. @Implements() registers the concrete class as its implementation. When the container resolves PaymentGateway, it transparently returns a StripeGateway instance.

Conditional binding

@Implements() accepts a when option for environment or context-driven binding:

@Injectable()
@Implements(PaymentGateway, { when: process.env.NODE_ENV !== 'test' })
class StripeGateway extends PaymentGateway { ... }

@Injectable()
@Implements(PaymentGateway, { when: process.env.NODE_ENV === 'test' })
class MockGateway extends PaymentGateway { ... }

When when evaluates to false, the decorator is a no-op — the class is not registered as an implementation.


Collection injection

When multiple classes share the same abstract class, use @RegisterAs() to register them as a collection, then retrieve all members with getMembersOf().

@Abstract()
abstract class EventHandler {
  abstract handle(event: DomainEvent): void;
}

@Injectable()
@RegisterAs(EventHandler)
class SendEmailOnUserRegistered extends EventHandler { ... }

@Injectable()
@RegisterAs(EventHandler)
class CreateAuditLogOnUserRegistered extends EventHandler { ... }

// Dispatch to all handlers
const handlers = container.getMembersOf(EventHandler);
for (const Handler of handlers) {
  container.get(Handler).handle(event);
}

getMembersOf() returns an array of constructors, not instances. This is intentional — resolving each class individually lets the container respect each class's own scope. A handler registered as singleton will be reused; a transient one will get a fresh instance each time.


Child containers

Child containers inherit singletons from their parent but maintain their own scope. This is useful for isolating dependencies per request in a server context.

const root = Container.create({ defaultScope: "singleton" });

// Per-request
const child = root.createChildContainer();
const handler = child.get(RequestHandler);

Singleton instances created in the root are shared across all child containers. Instances created in a child are scoped to that child.


Binding to a constant value

For testing or for injecting pre-constructed instances, use bindToConstantValue():

const container = Container.create();

const mockGateway = new MockPaymentGateway();
container.bindToConstantValue(PaymentGateway, mockGateway);

// container.get(PaymentGateway) now returns mockGateway

A class bound to a constant value cannot have an explicit transient scope — constant values are by definition stable references.


Auto-discovery

In large projects, manually importing every file to trigger decorator registration is impractical. lazy-di provides a file scanner that dynamically imports all TypeScript files under a given directory at startup:

import "reflect-metadata";
import { Container } from "@lazy-di/core";

const container = Container.create();

const result = await container.scan({ rootDir: "src" });

// All @Injectable classes under src/ are now registered
const app = container.get(App);

ScanResult

| Field | Type | Description | | --------------- | -------- | ----------------------------------- | | filesFound | number | Total files matched under rootDir | | filesImported | number | Files successfully imported | | filesSkipped | number | Files skipped due to import errors | | durationMs | number | Total scan duration in milliseconds |


Error messages

lazy-di throws descriptive errors at the point of misconfiguration, not silently at runtime.

| Scenario | Error | | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | reflect-metadata not loaded | reflect-metadata is not loaded. Add import 'reflect-metadata' as the first line of your entry point. | | emitDecoratorMetadata not enabled | No metadata found for "MyClass". Ensure emitDecoratorMetadata is true in your tsconfig and that the class has at least one decorator applied. | | Resolving non-injectable class | Cannot resolve dependency MyClass. Make sure it is decorated with @Injectable(). | | Resolving abstract class with no implementation | Cannot resolve dependency PaymentGateway. No implementation found. | | Duplicate @Implements registration | StripeGateway cannot implement PaymentGateway. PaymentGateway is already implemented by AnotherGateway. | | @Implements on non-abstract class | PaymentGateway must be decorated with @Abstract() before it can be implemented by another class. | | bindToConstantValue on transient class | Cannot bind MyClass to a constant value. A dependency bound to a constant value cannot have an explicit transient scope. |


Full API reference

Container

| Method | Description | | --------------------------------------------- | ------------------------------------------------------------------------------------- | | Container.create(options?) | Creates a new root container. Accepts { defaultScope }. | | container.get(token) | Resolves a dependency. Accepts a concrete class or an @Abstract() class. | | container.scan(options) | Dynamically imports all files under options.rootDir. | | container.createChildContainer() | Creates a child container that inherits singletons from the parent. | | container.bindToConstantValue(token, value) | Binds a class to a pre-constructed instance. | | container.getMembersOf(abstractClass) | Returns all constructors registered via @RegisterAs() for the given abstract class. |

Decorators

| Decorator | Target | Description | | --------------------------------------- | -------------- | ---------------------------------------------------------------- | | @Injectable({ scope? }) | Concrete class | Marks the class as resolvable. Scope is transient by default. | | @Abstract() | Abstract class | Marks the class as a valid injection token. | | @Implements(AbstractClass, { when? }) | Concrete class | Registers the class as the implementation of an abstract class. | | @RegisterAs(AbstractClass) | Concrete class | Registers the class as a member of an abstract class collection. |


Contributing

Contributions are welcome. Before opening a pull request:

  1. Fork the repo and create a branch from main.
  2. Run npm test to make sure all tests pass.
  3. Add tests for any new behaviour — coverage is currently at 100% and should stay there.
  4. Open a PR with a clear description of what changed and why.

For bugs, open an issue first so we can align on the fix before you write code. For larger features, open a discussion issue before starting work.


License

MIT