ts-iocc
v2.1.1
Published
Typescript Inversion of Control Container
Maintainers
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-ioccQuick 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, useCasesRegistering 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 callCircular 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.aThis 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 // ImmutableContainerErrorThe 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
validateContainerinstantiates 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) andSymbol.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
DisposalErroris 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:
factoriesvalues are(container) => Tfactory functions - Nested:
factoriesvalues are objects of(container) => Tfactory 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
AsyncDisposablesupport) - 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
