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

@stitchem/core

v0.0.8

Published

Extensible modular dependency injection for TypeScript.

Readme

@stitchem/core

Extensible modular dependency injection for TypeScript.

Built on TC39 decorators and modern JavaScript — no reflect-metadata, no legacy experimentalDecorators. Uses AsyncLocalStorage for scope propagation and Symbol.asyncDispose for deterministic cleanup.

Requirements

  • TypeScript >= 5.2 (TC39 decorators + AsyncDisposable)
  • Node.js >= 22 (or any runtime with AsyncLocalStorage + Symbol.asyncDispose)

Install

npm install @stitchem/core
# or
pnpm add @stitchem/core

Quick Start

import { injectable, module, Context } from '@stitchem/core';

@injectable()
class GreetingService {
  greet(name: string) {
    return `Hello, ${name}!`;
  }
}

@module({ providers: [GreetingService] })
class AppModule {}

await using ctx = await Context.create(AppModule);

const svc = await ctx.resolve(GreetingService);
console.log(svc.greet('World')); // Hello, World!

The await using syntax ensures the context (and all managed instances) is disposed when the scope exits. You can also call await ctx[Symbol.asyncDispose]() manually.


Table of Contents


Providers

Providers are the building blocks of the DI system. A provider tells the container how to create or locate a dependency.

Constructor Provider

The simplest form — just pass a class decorated with @injectable():

@injectable()
class UserService {
  getUsers() { return ['alice', 'bob']; }
}

@module({ providers: [UserService] })
class AppModule {}

Value Provider

Register a static value:

const DB_URL = Symbol('DB_URL');

@module({
  providers: [
    { provide: DB_URL, useValue: 'postgres://localhost/mydb' },
  ],
})
class AppModule {}

Value providers are always singletons. The value is used as-is — no instantiation or lifecycle hooks.

Factory Provider

Use a factory function to create instances. Dependencies can be injected into the factory via inject:

@module({
  providers: [
    ConfigService,
    {
      provide: 'DB_CONNECTION',
      useFactory: (config: ConfigService) => createConnection(config.dbUrl),
      inject: [ConfigService],
      lifetime: Lifetime.SCOPED,
    },
  ],
})
class DbModule {}

Class Provider

Map a token to a different class implementation:

@module({
  providers: [
    { provide: PaymentService, useClass: StripePaymentService },
  ],
})
class PaymentModule {}

Existing Provider

Alias one token to another:

@module({
  providers: [
    RealLogger,
    { provide: LOGGER, useExisting: RealLogger },
  ],
})
class AppModule {}

Dependency Injection

Constructor Injection

Declare dependencies with a static inject property. The container resolves them in order and passes them to the constructor:

@injectable()
class UserService {
  static inject = [UserRepository, LOGGER] as const;
  constructor(
    private repo: UserRepository,
    private logger: Logger,
  ) {}
}

The as const assertion ensures type safety. Any Token type can be used — classes, symbols, or strings.

Accessor Injection

Use the @inject() decorator on accessor properties for field-based injection:

@injectable()
class OrderService {
  @inject(LOGGER) accessor logger!: Logger;
  @inject(UserService) accessor users!: UserService;

  process() {
    this.logger.info('Processing order...');
    return this.users.getUsers();
  }
}

Accessor injection is resolved after construction. Both injection styles can be mixed in the same class.


Lifetimes

Every provider has a lifetime that controls how long its instance lives.

Singleton

Default. One instance shared across the entire container. Created once during initialization.

@injectable() // or @injectable({ lifetime: Lifetime.SINGLETON })
class DatabasePool {}

Transient

A new instance is created every time the dependency is resolved. Never cached.

@injectable({ lifetime: Lifetime.TRANSIENT })
class RequestHandler {}

const a = await ctx.resolve(RequestHandler);
const b = await ctx.resolve(RequestHandler);
// a !== b — always a fresh instance

Scoped

One instance per scope. Within the same scope, the same instance is returned. Different scopes get different instances.

@injectable({ lifetime: Lifetime.SCOPED })
class RequestContext {
  requestId = crypto.randomUUID();
}

Scoped providers require an active scope (via withScope or createScope). Attempting to resolve a scoped provider without a scope throws SCOPED_RESOLUTION. A singleton cannot depend on a scoped provider — this is caught at initialization time.


Scopes

Scopes enable request-level isolation. Every HTTP request, WebSocket connection, or job can have its own scope with isolated state.

withScope

The simplest way to create a scope. The scope is automatically disposed when the callback completes:

await ctx.withScope(async () => {
  // All resolutions inside this callback share the same scope.
  const reqCtx = await ctx.resolve(RequestContext);
  const logger = await ctx.resolve(RequestLogger);
  // logger.reqCtx === reqCtx (same scope → same instance)
});
// Scope disposed here — all scoped instances cleaned up.

withScope also disposes scoped instances when the callback throws:

await ctx.withScope(async () => {
  await ctx.resolve(DbTransaction);
  throw new Error('failed');
});
// DbTransaction.onDispose() still called

createScope

For manual scope management. Useful when the scope lifetime doesn't align with a single callback:

const scope = ctx.createScope();

// Run code within the scope
const svc = await scope.run(() => ctx.resolve(RequestContext));

// Same scope → same instance
const same = await scope.run(() => ctx.resolve(RequestContext));
assert(svc === same);

// Dispose when done
await scope[Symbol.asyncDispose]();

Or with await using:

{
  await using scope = ctx.createScope();
  const svc = await scope.run(() => ctx.resolve(RequestContext));
}
// Scope auto-disposed at block exit

Scopes nest naturally. Inner scopes get their own instances; outer scopes are unaffected:

await ctx.withScope(async () => {
  const outer = await ctx.resolve(ScopedSvc);

  await ctx.withScope(async () => {
    const inner = await ctx.resolve(ScopedSvc);
    // inner !== outer — different scope
  });

  const stillOuter = await ctx.resolve(ScopedSvc);
  // stillOuter === outer — outer scope unchanged
});

Modules

Modules group related providers and define the dependency graph. Every stitchem application has at least one root module.

@module({
  imports: [DatabaseModule, AuthModule],
  providers: [UserService, UserRepository],
  exports: [UserService],
})
class UserModule {}

Imports and Exports

Providers are private to their module by default. To make a provider available to other modules, export it:

// DatabaseModule exports DbConnection
@module({
  providers: [DbConnection],
  exports: [DbConnection],
})
class DatabaseModule {}

// UserModule imports DatabaseModule to access DbConnection
@module({
  imports: [DatabaseModule],
  providers: [UserService],
})
class UserModule {}

Only exported tokens are visible to importing modules. This encapsulation prevents accidental coupling.

Global Modules

A global module's exports are available to all modules without explicit imports:

@module({
  global: true,
  providers: [
    { provide: 'APP_NAME', useValue: 'MyApp' },
  ],
  exports: ['APP_NAME'],
})
class ConfigModule {}

// No need to import ConfigModule — 'APP_NAME' is available everywhere
@injectable()
class AnyService {
  static inject = ['APP_NAME'] as const;
  constructor(public appName: string) {}
}

Use sparingly. Global modules are convenient for cross-cutting concerns like configuration, logging, and caching.

Dynamic Modules

For configurable modules, use a static factory method that returns a DynamicModule:

import type { DynamicModule } from '@stitchem/core';

@module()
class CacheModule {
  static forRoot(options: { ttl: number }): DynamicModule {
    return {
      module: CacheModule,
      global: true,
      providers: [
        { provide: 'CACHE_TTL', useValue: options.ttl },
        CacheService,
      ],
      exports: [CacheService],
    };
  }
}

@module({
  imports: [CacheModule.forRoot({ ttl: 300 })],
})
class AppModule {}

Dynamic module metadata is merged with the base @module() metadata. Array fields (like providers) are concatenated.

Re-exports

A module can re-export an imported module, making its exports available to its own consumers:

@module({
  imports: [CoreModule],
  exports: [CoreModule],   // Re-exports everything CoreModule exports
})
class SharedModule {}

@module({ imports: [SharedModule] })
class AppModule {}
// AppModule can now resolve CoreModule's exports through SharedModule

Lifecycle Hooks

Providers can implement lifecycle hooks for initialization, readiness, and cleanup.

OnInit

Called during instantiation, after the constructor and dependency injection. Supports async.

import type { OnInit } from '@stitchem/core';

@injectable()
class DbConnection implements OnInit {
  static inject = ['DB_URL'] as const;
  private pool: any;

  constructor(private url: string) {}

  async onInit() {
    this.pool = await createPool(this.url);
  }
}

If onInit throws, Context.create() fails — preventing the application from starting with broken dependencies.

OnReady

Called after all singleton providers have been created and initialized. Fires once per application lifecycle. Use it for work that depends on the full dependency graph being ready.

import type { OnReady } from '@stitchem/core';

@injectable()
class HttpServer implements OnReady {
  onReady() {
    console.log('All services initialized, starting server...');
    this.listen();
  }
}

OnDispose

Called when the context (or scope) is disposed. Use it for cleanup — closing connections, releasing resources, flushing buffers.

import type { OnDispose } from '@stitchem/core';

@injectable()
class DbConnection implements OnDispose {
  async onDispose() {
    await this.pool.end();
    console.log('Database pool closed');
  }
}

Ordering: onInit fires during creation (dependency order). onReady fires after all singletons are initialized. onDispose fires in reverse module order — dependents are disposed before their dependencies.


Circular Dependencies

Circular dependencies (A → B → A) are detected at resolution time and throw CIRCULAR_DEPENDENCY. To break the cycle, use lazy() on one side:

import { lazy } from '@stitchem/core';

@injectable()
class AuthService {
  static inject = [lazy(() => UserService)] as const;
  constructor(private users: UserService) {}
}

@injectable()
class UserService {
  static inject = [AuthService] as const;
  constructor(private auth: AuthService) {}
}

Only one side of the cycle needs lazy(). The lazy side receives a proxy that resolves on first property access. lazy() also works in factory inject arrays.


Logger

Every module gets a LOGGER provider automatically. Inject it with the LOGGER symbol:

import { LOGGER } from '@stitchem/core';
import type { Logger } from '@stitchem/core';

@injectable()
class UserService {
  static inject = [LOGGER] as const;
  constructor(private logger: Logger) {}

  create(name: string) {
    this.logger.info(`Creating user: ${name}`);
  }
}

The Logger interface is minimal and compatible with console, pino(), and winston.createLogger():

interface Logger {
  info(message: string, ...args: unknown[]): void;
  warn(message: string, ...args: unknown[]): void;
  error(message: string, ...args: unknown[]): void;
  debug(message: string, ...args: unknown[]): void;
}

Custom Logger

Pass your own logger (or disable logging) when creating the context:

// Use pino
const ctx = await Context.create(AppModule, { logger: pino() });

// Disable logging
const ctx = await Context.create(AppModule, { logger: false });

ConsoleLogger

The built-in ConsoleLogger formats output with timestamps and colored symbols:

12:30:15 [http] ℹ Starting server on port 3000
12:30:15 [http] ⚠ TLS certificate expires in 7 days
12:30:17 [db]   ✗ Connection failed: ECONNREFUSED
12:30:18 [db]   ◆ Retry succeeded

Control verbosity with LogLevel:

import { ConsoleLogger, LogLevel } from '@stitchem/core';

ConsoleLogger.setLevel(LogLevel.WARN); // Only warn + error

Components

Components are an extension mechanism for framework authors. They allow modules to declare classes (via custom metadata keys) that the core will instantiate with full DI and lifecycle support.

// Extend ModuleMetadata in your package
declare module '@stitchem/core' {
  interface ModuleMetadata {
    routers?: constructor[];
  }
}

// Use the custom key in modules
@module({
  providers: [DbService],
  routers: [UserRouter, HealthRouter],
})
class ApiModule {}

// Tell the core to manage the 'routers' key
const ctx = await Context.create(AppModule, {
  componentKeys: ['routers'],
});

// Retrieve all resolved router instances
const routers = ctx.getComponents('routers');
for (const { instance, classRef, moduleRef } of routers) {
  console.log(`${classRef.name} from ${moduleRef.id}`);
}

Components receive full dependency injection, onInit, onReady, and onDispose lifecycle hooks. They are returned in module dependency order.


ModuleRef

ModuleRef is automatically registered in every module. Inject it for dynamic resolution and module introspection:

import { ModuleRef } from '@stitchem/core';

@injectable()
class PluginLoader {
  static inject = [ModuleRef] as const;
  constructor(private moduleRef: ModuleRef) {}

  async loadPlugin<T>(token: Token<T>): Promise<T> {
    return this.moduleRef.resolve(token);
  }
}

ModuleRef also supports:

  • create(ctor) — creates a new instance bypassing the cache (useful for transient-like behavior on demand)
  • constructClass(ctor) — instantiates any class by resolving its dependencies, even if unregistered
  • select(moduleClass) — navigates to a different module
  • getComponents(key) — gets component instances for the module

From the Context, use select() to navigate to a specific module:

const dbRef = ctx.select(DatabaseModule);
const connection = await dbRef.resolve(DbConnection);

Error Handling

All DI errors are instances of CoreError with a machine-readable code and structured context:

import { CoreError, ErrorCode } from '@stitchem/core';

try {
  await ctx.resolve(UnknownService);
} catch (err) {
  if (err instanceof CoreError) {
    switch (err.code) {
      case ErrorCode.PROVIDER_NOT_FOUND:
        console.error('Unknown service:', err.context.token);
        break;
      case ErrorCode.CIRCULAR_DEPENDENCY:
        console.error('Cycle detected:', err.context.path);
        break;
    }
  }
}

Error codes:

| Code | When | |------|------| | CIRCULAR_DEPENDENCY | Provider dependency cycle detected | | SCOPED_RESOLUTION | Scoped provider resolved without a scope | | UNKNOWN_DEPENDENCY | A dependency token could not be resolved | | INVALID_MODULE | Class passed to Context.create() is not decorated with @module() | | CIRCULAR_MODULE_DEPENDENCY | Module import graph has a cycle | | MODULE_NOT_FOUND | Module class not found in container | | MODULE_NOT_EXPORTED | Module listed in exports but not imports | | NOT_INJECTABLE | Constructor provider not decorated with @injectable() | | INVALID_PROVIDER | Provider configuration is malformed | | PROVIDER_NOT_FOUND | Token not registered in any module | | PROVIDER_NOT_VISIBLE | Provider exists but is not exported to the requesting module |


Testing

The Test utility provides a builder for creating isolated test modules with provider overrides.

Basic Setup

import { Test } from '@stitchem/core';
import type { TestModule } from '@stitchem/core';
import { describe, it, expect, afterEach } from 'vitest';

describe('UserService', () => {
  let testModule: TestModule;

  afterEach(async () => {
    await testModule?.close();
  });

  it('should return users', async () => {
    testModule = await Test.createModule({
      imports: [UserModule],
    }).compile();

    const svc = await testModule.resolve(UserService);
    expect(svc.getUsers()).toEqual(['alice', 'bob']);
  });
});

Or with await using for automatic cleanup:

it('should return users', async () => {
  await using testModule = await Test.createModule({
    imports: [UserModule],
  }).compile();

  const svc = await testModule.resolve(UserService);
  expect(svc.getUsers()).toEqual(['alice', 'bob']);
});

Overriding Providers

Replace real dependencies with mocks:

await using testModule = await Test.createModule({
  imports: [UserModule],
})
  .overrideProvider(DatabaseService)
  .useValue({ query: vi.fn().mockResolvedValue([]) })
  .compile();

Override methods:

  • .useValue(value) — replace with a static value
  • .useClass(MockClass) — replace with a different class
  • .useFactory({ factory, inject? }) — replace with a factory
  • .useExisting(otherToken) — alias to another token

Scoped Testing

it('should isolate scoped state', async () => {
  await using testModule = await Test.createModule({
    providers: [ScopedService],
  }).compile();

  await using scope = testModule.createScope();
  const a = await testModule.resolve(ScopedService, scope);
  const b = await testModule.resolve(ScopedService, scope);
  expect(a).toBe(b); // Same scope → same instance
});

Module Navigation

Navigate to a specific module within the test:

const ref = testModule.select(DatabaseModule);
const db = await ref.resolve(DatabaseService);

API Reference

Decorators

| Decorator | Description | |-----------|-------------| | @injectable(options?) | Marks a class as injectable. Options: { lifetime?: Lifetime } | | @inject(token) | Accessor decorator for property injection | | @module(metadata?) | Marks a class as a module |

Context

| Method | Description | |--------|-------------| | Context.create(rootModule, options?) | Creates and initializes a context | | ctx.resolve(token, options?) | Resolves a provider (async) | | ctx.resolveSync(token, options?) | Resolves a provider (sync) | | ctx.constructClass(ctor, options?) | Instantiates any class with DI | | ctx.withScope(fn) | Runs callback in a new auto-disposed scope | | ctx.createScope() | Creates a manual disposable scope | | ctx.select(moduleClass) | Navigates to a specific module | | ctx.getComponents(key) | Gets component instances for a metadata key | | ctx[Symbol.asyncDispose]() | Disposes the context and all instances |

Lifetime

| Value | Behavior | |-------|----------| | Lifetime.SINGLETON | One instance for the entire container (default) | | Lifetime.TRANSIENT | New instance on every resolution | | Lifetime.SCOPED | One instance per scope |

Token Types

Tokens identify dependencies. Any of these can be used as a token:

  • Class constructorUserService
  • SymbolSymbol('DB_URL')
  • String'CONFIG'
  • Lazy tokenlazy(() => UserService)

License

MIT