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

ts-iocc

v2.1.1

Published

Typescript Inversion of Control Container

Readme

ts-iocc

Type-safe Inversion of Control Container for TypeScript.

  • Full type safety — compile-time errors name your missing registrations
  • No decorators, no reflect-metadata — just generics and factory functions
  • Lazy singletons — nothing instantiated until first access
  • AsyncDisposable — deterministic resource cleanup via await using
  • Zero runtime dependencies

Install

npm install ts-iocc

Quick Start

import { createContainer } from 'ts-iocc'

// 1. Define your dependency interfaces
interface UserRepo {
  findById(id: string): User | undefined
}
interface EmailService {
  send(to: string, body: string): void
}

// 2. Define the registry — the shape of your entire dependency graph
interface Registry {
  repos: { user: UserRepo }
  services: { email: EmailService }
}

// 3. Register factories and build
const container = createContainer<Registry>()
  .register('repos', {
    user: () => ({ findById: (id) => db.users.get(id) }),
  })
  .register('services', {
    email: (c) => ({
      send: (to, body) => mailer.send(to, body),
    }),
  })
  .build()

// 4. Use it — lazy, singleton, type-safe
const user = container.repos.user.findById('123')

Why ts-iocc?

TypeScript IoC containers typically rely on decorators and reflect-metadata — runtime magic that breaks with modern bundlers and gives you runtime errors instead of compile-time feedback. ts-iocc uses plain interfaces and factory functions so tsc catches your mistakes, not a stack trace at 2am.

Full Example

A realistic container with flat categories (repos, services), nested categories (useCases), and cross-category resolution:

import { createContainer, validateContainer } from 'ts-iocc'

// ── Interfaces ──

interface UserRepo {
  findById(id: string): User | undefined
}
interface OrderRepo {
  findByUserId(userId: string): Order[]
}
interface EmailService {
  send(to: string, body: string): void
}
interface CreateUserCmd {
  execute(name: string): User
}
interface GetUserQuery {
  execute(id: string): User | undefined
}

// ── Registry ──

interface AppRegistry {
  repos: { user: UserRepo; order: OrderRepo }
  services: { email: EmailService }
  useCases: {
    commands: { createUser: CreateUserCmd }
    queries: { getUser: GetUserQuery }
  }
}

// ── Container ──

const container = createContainer<AppRegistry>()
  .register('repos', {
    user: () => new PgUserRepo(pool),
    order: () => new PgOrderRepo(pool),
  })
  .register('services', {
    email: (c) => new SmtpEmailService(c.repos.user),
  })
  .register('useCases', {
    commands: {
      createUser: (c) => new CreateUserCmdImpl({
        repos: { user: c.repos.user },
        services: { email: c.services.email },
      }),
    },
    queries: {
      getUser: (c) => new GetUserQueryImpl({
        repos: { user: c.repos.user },
      }),
    },
  })
  .build()

// ── Validate at startup ──

const result = validateContainer(container)
if (!result.ok) {
  for (const err of result.errors) {
    console.error(`${err.category}.${err.dependency}:`, err.error)
  }
  process.exit(1)
}

// ── Use ──

const user = container.useCases.queries.getUser.execute('123')
container.useCases.commands.createUser.execute('alice')

Features

Flat and Nested Categories

The registry supports two levels of structure:

Flat categories — values are factory functions directly. Use these for simple groups like repos and services:

.register('repos', {
  user: () => new PgUserRepo(pool),
  order: () => new PgOrderRepo(pool),
})

Nested categories — values are objects of factory functions, one level deep. Use these for structured groups like use cases:

.register('useCases', {
  commands: {
    createUser: (c) => new CreateUserCmdImpl(c.repos.user),
  },
  queries: {
    getUser: (c) => new GetUserQueryImpl(c.repos.user),
  },
})

The registry supports exactly these two shapes. You can mix flat and nested categories in the same container, but nesting beyond two levels (category → sub-group → dependency) is not supported.

Cross-Category Resolution

Every factory receives the full container as its argument, so dependencies can resolve other dependencies across categories:

.register('services', {
  email: (c) => new SmtpEmailService(c.repos.user), // ← resolves from repos
})

The c parameter is fully typed — autocomplete works, and accessing a nonexistent dependency is a compile error.

Compile-Time Completeness

If you forget to register a category, build() produces a type error naming exactly what's missing:

createContainer<AppRegistry>()
  .register('repos', { /* ... */ })
  .build()
//       ^  Type error: Missing registrations: services, useCases

Registering the same category twice is also caught at both the type level and runtime (DuplicateRegistrationError).

Lazy Singletons

Dependencies are instantiated on first access, not at build() time. Once created, the same instance is returned on every subsequent access:

container.repos.user // factory runs, instance created
container.repos.user // same instance returned, no factory call

Circular Dependency Detection

If two dependencies reference each other, you get a CircularDependencyError with the full resolution chain:

.register('repos', {
  a: (c) => { c.repos.b; return new A() },
  b: (c) => { c.repos.a; return new B() },
})

container.repos.a
// CircularDependencyError: Circular dependency detected: 'repos.a' is already
// being resolved. Resolution chain: repos.a → repos.b → repos.a

This works across categories and at any depth.

"Did You Mean?" Suggestions

Typos in dependency or category names produce helpful error messages with Levenshtein-based suggestions:

container.servics
// UnregisteredCategoryError: Category 'servics' is not registered.
// Registered: repos, services. Did you mean 'services'?

container.repos.usre
// UnregisteredDependencyError: Dependency 'usre' is not registered in 'repos'.
// Available: user, order. Did you mean 'user'?

Immutable Containers

Built containers are structurally immutable. Any attempt to reassign a category slot or dependency key throws ImmutableContainerError:

container.repos.user = new FakeRepo()  // ImmutableContainerError
delete container.repos.user            // ImmutableContainerError

The container enforces immutability on its own structure (category and dependency slots), not on the resolved instances themselves. If your UserRepo has mutable fields, those remain mutable — the container doesn't wrap them in Readonly.

Container Forking

forkContainer creates a new container by cloning an existing one and selectively replacing factories. The original container is unaffected. This is designed for testing — swap a real adapter for a mock without rebuilding the entire container:

import { describe, it, expect } from 'vitest'
import { forkContainer } from 'ts-iocc'
import { container } from './container.js' // your production container

describe('CreateUserCmd', () => {
  it('should send a welcome email on user creation', () => {
    const sentEmails: string[] = []
    const mockEmail: EmailService = {
      send: (to, body) => { sentEmails.push(to) },
    }

    const testContainer = forkContainer(container, {
      services: { email: () => mockEmail },
    })

    testContainer.useCases.commands.createUser.execute('alice')
    expect(sentEmails).toContain('[email protected]')
  })
})

Forked containers get fresh instances for all dependencies — nothing is shared with the original, even for non-overridden factories.

Nested categories work the same way:

const testContainer = forkContainer(container, {
  useCases: {
    commands: {
      createUser: () => mockCreateUserCmd,
    },
  },
})

Validation

validateContainer eagerly resolves every registered dependency and collects any errors, without throwing. Use it in tests or at application startup to catch wiring problems early.

Performance note: Calling validateContainer instantiates all registered dependencies immediately, which means every factory runs and every singleton is created. This deliberately defeats lazy initialization for that container instance. In a test this is exactly what you want — it proves the entire dependency graph is wirable. At server startup it's a trade-off: you get fail-fast confidence that nothing is misconfigured, but you pay the full initialization cost upfront (database connections, HTTP clients, etc.) even for dependencies that may not be needed on every request. If startup latency is critical, prefer running validation in a dedicated integration test rather than in production boot.

import { validateContainer } from 'ts-iocc'

const result = validateContainer(container)

if (!result.ok) {
  for (const err of result.errors) {
    console.error(`${err.category}.${err.dependency}:`, err.error)
  }
  process.exit(1)
}

As a test (recommended):

it('should resolve all dependencies without errors', () => {
  const result = validateContainer(container)
  expect(result).toEqual({ ok: true })
})

Async Disposal

Containers implement AsyncDisposable, so dependencies that hold resources (database pools, HTTP clients, file handles) are cleaned up automatically when the container is disposed.

With await using (recommended):

async function main() {
  await using container = createContainer<AppRegistry>()
    .register('repos', {
      user: () => new PgUserRepo(pool),  // pool implements AsyncDisposable
    })
    .build()

  // Use the container...
  container.repos.user.findById('123')

  // container[Symbol.asyncDispose]() called automatically on scope exit,
  // even if an exception is thrown
}

Or call it explicitly:

const container = createContainer<AppRegistry>()
  .register('repos', { /* ... */ })
  .build()

// ... use the container ...

await container[Symbol.asyncDispose]()

How it works:

  • Only instantiated dependencies are disposed — unaccessed lazy dependencies are skipped
  • Disposal runs in reverse instantiation order, which naturally respects the dependency graph: if Service A depends on Repo B, Repo B was instantiated first, so A is disposed before B
  • Both Symbol.asyncDispose (async) and Symbol.dispose (sync) are checked on each instance, with async preferred
  • Non-disposable instances are silently skipped
  • Disposal is idempotent — calling it twice is a no-op
  • After disposal, any access to the container throws DisposedContainerError
  • If one or more dependencies throw during disposal, the remaining dependencies are still disposed and a DisposalError is thrown with all failures

Forked containers have independent disposal — disposing a fork does not affect the original, and vice versa.

API Reference

createContainer<R>()

Returns a ContainerBuilder<R> for the given registry type R.

ContainerBuilder<R, Registered>

The builder class. Registered is a type-level accumulator that tracks which registry keys have been registered — you don't set it yourself.

.register(key, factories)

Registers a category. Returns the builder for chaining.

  • Flat: factories values are (container) => T factory functions
  • Nested: factories values are objects of (container) => T factory functions

The shape (flat vs nested) is inferred from the values you pass. All values must be the same shape — mixing functions and objects in a single .register() call throws InvalidRegistrationError.

.build()

Builds and returns a Container<R>. Produces a compile error if any registry keys are unregistered, naming the missing keys in the error message.

forkContainer<R>(container, overrides)

Creates a new Container<R> by cloning the original's factory storage and applying selective overrides. The original container is not modified.

type ContainerOverrides<R> = {
  [K in keyof R]?: {
    [P in keyof R[K]]?: DependencyFactory<R[K][P], Container<R>>
      | { [SP in keyof R[K][P]]?: DependencyFactory<R[K][P][SP], Container<R>> }
  }
}

validateContainer<R>(container)

Eagerly resolves all registered dependencies. Returns:

type ValidationResult =
  | { ok: true }
  | { ok: false; errors: ValidationError[] }

interface ValidationError {
  category: string    // e.g. "repos" or "useCases.commands"
  dependency: string  // e.g. "user"
  error: unknown      // the caught error
}

isTsIoccError(err)

Returns true if err is any error thrown by ts-iocc. Useful for distinguishing container errors from application errors in catch blocks.

Error Classes

| Error | Thrown when | Structured data | |---|---|---| | CircularDependencyError | A dependency is already being resolved | .data.dependency, .data.resolutionChain | | UnregisteredDependencyError | Accessing a dependency that was never registered | .data.category, .data.dependency, .data.availableDependencies, .data.suggestion? | | UnregisteredCategoryError | Accessing a top-level category that was never registered | .data.category, .data.registeredCategories, .data.suggestion? | | DuplicateRegistrationError | Calling .register() with an already-registered key | .data.category | | InvalidRegistrationError | Passing invalid values to .register() | .data.category, .data.reason | | ImmutableContainerError | Attempting to set or delete on a built container | .message includes the operation (set/delete) and property name | | InvalidContainerError | Passing a non-ts-iocc object to forkContainer or validateContainer | — | | DependencyResolutionError | A factory function throws during resolution | .data.category, .data.dependency, .cause has the original error | | DisposedContainerError | Accessing a container after it has been disposed | .data.category?, .data.dependency? | | DisposalError | One or more dependencies throw during disposal | .data.failures — array of { key, error } |

All errors extend Error and can be identified with isTsIoccError().

Type Exports

import type {
  Container,           // Container<R> — the built container type (& AsyncDisposable)
  DependencyFactory,   // (container: C) => T — factory function type
  ContainerOverrides,  // Override map for forkContainer
  ValidationResult,    // { ok: true } | { ok: false, errors }
  ValidationError,     // { category, dependency, error }
} from 'ts-iocc'

Requirements

  • Node.js >= 20
  • TypeScript >= 5.2 (required for AsyncDisposable support)
  • ESM only ("type": "module")

Migrating to 2.0

DeepReadonly removed

Container<R> no longer wraps resolved instances in DeepReadonly. If you were casting (as unknown as MyType) to work around readonly friction when passing container-resolved dependencies to code expecting concrete types, those casts can be removed.

If you relied on DeepReadonly independently, copy the type into your project:

type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K]
}

Container<R> type change

The container type now uses structural-only readonly ({ readonly [K in keyof R]: Readonly<R[K]> }). Category keys and dependency slots are readonly at the type level, but resolved instances retain their original types. There is no runtime behavior change — proxy traps still enforce full structural immutability.

License

MIT