rsdi
Version:
TypeScript dependency injection container. Strong types without decorators.
455 lines (333 loc) • 16.7 kB
Markdown
# RSDI - Simple & Strong-Type Dependency Injection Container
[](https://www.npmjs.com/package/rsdi)
[](https://github.com/radzserg/rsdi/actions/workflows/ci.yml)
[](./LICENSE)
Manage your dependencies with ease and safety. RSDI is a minimal, powerful DI container with full TypeScript support — no decorators or metadata required.
> **Using an AI coding agent?** Point it at
> **[docs/ai-agent-guide.md](./docs/ai-agent-guide.md)** — a single-page integration guide covering
> the API, the mistakes that don't compile, how to structure a large container, and how to decode
> RSDI's error messages. Every example in it is compile- and runtime-verified.
- [Motivation](#motivation)
- [Features](#features)
- [Installation](#installation)
- [Best Use Cases](#best-use-cases)
- [Architecture](#architecture)
- [How to use](#how-to-use)
- [Strict types](#strict-types)
- [Advanced Usage](#advanced-usage)
- [Extend](#extend)
- [Compose](#compose)
- [Merge](#merge)
- [Clone](#clone)
- [Naming your container type](#naming-your-container-type)
- [Other methods](#other-methods)
- [Further reading](#further-reading)
## Motivation
Most DI libraries rely on reflect-metadata and decorators to auto-wire dependencies. But this tightly couples
your business logic to a framework — and adds complexity:
```typescript
@injectable()
class Foo {
constructor(@inject('Database') private database?: Database) {}
}
// Notice how in order to allow the use of the empty constructor new Foo(),
// we need to make the parameters optional, e.g. database?: Database.
```
Why should your core logic even know it's injectable?
RSDI avoids this by using explicit factory functions — keeping your code clean, framework-agnostic, and easy to test.
[Read more](https://radzserg.medium.com/https-medium-com-radzserg-dependency-injection-in-react-part-2-995e93b3327c)
## Features
- No decorators
- Strong TypeScript support
- Simple API
- No runtime dependencies
- Easy to mock and test
- Scales to large graphs — [compose](#compose) independent modules instead of one long chain
## Installation
```bash
npm install rsdi
# or
pnpm add rsdi
# or
yarn add rsdi
```
```typescript
import { DIContainer } from 'rsdi';
```
## Best Use Cases
Use `RSDI` when your app grows in complexity:
- You break big modules into smaller ones
- You have deep dependency trees (A → B → C)
- You want to pass dependencies across layers:
- Controllers
- Domain managers
- Repositories
- Infrastructure services
## Architecture
`RSDI` works best when you organize your app as a dependency tree.
A typical backend app might have:
- Controllers (REST or GraphQL)
- Domain managers (use-cases, handlers)
- Repositories (DB access)
- Infrastructure (DB pools, loggers)

Set up your DI container at the app entry point — from there, all other parts can pull in what they need.
## How to use
### Basic Example
```typescript
const container = new DIContainer()
.add('a', () => 'name1')
.add('bar', () => new Bar())
.add('foo', ({ a, bar }) => new Foo(a, bar));
const { foo } = container; // alternatively container.get("foo");
```
### Real-World Example
```typescript
// sample web application components
export function UserController(userRegistrator: UserRegistrator, userRepository: UserRepository) {
return {
async create(req: Request, res: Response) {
const user = await userRegistrator.register(req.body);
res.send(user);
},
async list(req: Request, res: Response) {
const users = await userRepository.findAll(req.body);
res.send(users);
},
};
}
export class UserRegistrator {
public constructor(public readonly userRepository: UserRepository) {}
public async register(userData: SignupData) {
// validate and send sign up email
return this.userRepository.saveNewUser(userData);
}
}
export function MyDbProviderUserRepository(db: DbConnection): UserRepository {
return {
async saveNewUser(userAccountData: SignupData): Promise<void> {
await db('insert').insert(userAccountData);
},
};
}
export function buildDbConnection(): DbConnection {
return connectToDb({/* db credentials */});
}
```
Now let's configure the dependency injection container. Dependencies are only created when they're actually needed.
Your `configureDI` function will declare and connect everything in one place.
```typescript
import { DIContainer } from 'rsdi';
export type AppDIContainer = ReturnType<typeof configureDI>;
export default function configureDI() {
return new DIContainer()
.add('dbConnection', () => buildDbConnection())
.add('userRepository', ({ dbConnection }) => MyDbProviderUserRepository(dbConnection))
.add('userRegistrator', ({ userRepository }) => new UserRegistrator(userRepository))
.add('userController', ({ userRepository, userRegistrator }) =>
UserController(userRegistrator, userRepository),
);
}
```
When a resolver runs for the first time, its result is cached and reused for future calls.
By default, you should always use `.add()` to register dependencies — it throws if the name already exists, which
prevents accidental overwrites and keeps your setup predictable. If you need to replace an existing dependency —
usually in tests — use `.update()` instead:
```typescript
const container = configureDI();
// override a real dependency with a stub in tests
container.update('userRepository', () => new InMemoryUserRepository());
```
Let's map our web application routes to configured controllers
```typescript
// configure Express router
export default function configureRouter(app: core.Express, diContainer: AppDIContainer) {
const { userController } = diContainer;
app.route('/users').get(userController.list).post(userController.create);
}
```
Add `configureDI()` in your app's entry point:
```typescript
// express.ts
const app = express();
const diContainer = configureDI();
configureRouter(app, diContainer);
app.listen(8000);
```
🔗 Full example: [Express + RSDI](https://radzserg.medium.com/dependency-injection-in-express-application-dd85295694ab)
## Strict types
`RSDI` uses TypeScript's type system to validate dependency trees at compile time, not runtime.

This gives you autocomplete and safety without decorators or metadata hacks.
## Advanced Usage
As your application grows, it's a good idea to split your DI container setup into smaller, focused modules. This keeps
your codebase easier to navigate and maintain.
A common pattern is to keep a main `diContainer.ts` file that configures the base container and delegate domain-specific
dependencies to separate files like `dataAccess.ts`, `validators.ts`, or `controllers.ts`.
This modular structure improves testability, readability, and clarity on how dependencies are wired across your app.
---
### Extend
You can extend a container with more dependencies using `.extend()`. This is ideal for building up your container in logical steps.
```ts
// diContainer.ts
export const configureDI = async () => {
return (await buildDatabaseDependencies())
.extend(addDataAccessDependencies)
.extend(addValidators);
};
```
```ts
// addDataAccessDependencies.ts
export type DIWithPool = Awaited<ReturnType<typeof buildDatabaseDependencies>>;
export const addDataAccessDependencies = async () => {
const pool = await createDatabasePool();
const longRunningPool = await createLongRunningDatabasePool();
return new DIContainer()
.add('databasePool', () => pool)
.add('longRunningDatabasePool', () => longRunningPool);
};
```
```ts
// addValidators.ts
export type DIWithValidators = ReturnType<typeof addValidators>;
export const addValidators = (container: DIWithPool) => {
return container
.add('myValidatorA', ({ a, b, c }) => new MyValidatorA(a, b, c))
.add('myValidatorB', ({ a, b, c }) => new MyValidatorB(a, b, c));
};
```
> **On long chains, annotate the return type.** Deriving each module's input from the previous module's
> output (`(c: ReturnType<typeof previousModule>) => …`) is tempting, but it keeps every module's type nested
> inside the one before it, so instantiation depth accumulates down the whole chain and your build becomes
> coupled to RSDI's internals. Past a handful of modules this shows up as `TS2589` or a container that
> collapses to `never`. Give each module an explicit named return type — `(c: DIWithPool): DIWithValidators
=> …` — so every boundary flattens the accumulated type. A [`SealedContainer`](#naming-your-container-type)
> boundary every few modules gets most of the same benefit.
---
### Compose
`DIContainer.compose()` combines independently built containers into one new container. It is the recommended way to
wire a large dependency graph, and it keeps the inputs untouched.
```ts
// repositories.ts
export const repositories = new DIContainer().add('userRepository', () => new UserRepository());
// services.ts — declare what this module expects the composed container to provide
export const services = new DIContainer<{ userRepository: UserRepository }>().add(
'userService',
({ userRepository }) => new UserService(userRepository),
);
// container.ts
const container = DIContainer.compose(repositories, services);
container.userService; // UserService — fully typed
```
Resolution stays lazy and happens against the composed container, so a factory may depend on names provided by any of
the composed modules. Only the _types_ of a module are limited to what that module declares — annotate the module (as
`services` does above) or use `.extend()` when you need another module's types to be visible while writing it.
If several containers define the same name, the last one wins at runtime — including over a value an
earlier container had already resolved. Be aware the _types_ intersect rather than overwrite, so the
same name registered with two different types resolves to `never` instead of the later type. That
surfaces the collision rather than hiding it; if a replacement is intentional, use `.update()`.
#### Why compose instead of one long chain
Each `.add()` widens the container type, so a single chain of N dependencies costs **O(N²)** to type-check. Splitting
the graph into modules keeps each chain short, and the compiler only pays the quadratic within a module. Measured with
TypeScript 7 on this repo's benchmark (1600 dependencies, no factory arguments):
| Layout | Type instantiations | Check time |
| ------------------------------ | ------------------: | ---------: |
| one chain of 1600 | 7,815,223 | 90.2 s |
| 80 modules of 20, then compose | 361,552 | 0.9 s |
That is ~22× fewer instantiations and ~100× faster. If your editor feels sluggish in the file that wires your
container, this is usually why.
**Splitting alone is not the win — isolation is.** A module is cheap when it is checked against only the
dependencies it declares it consumes. In a production app with ~340 dependencies, splitting a flat chain into
`.extend()` modules that thread the whole accumulated container measured no better than the flat chain
(3.41M → 3.48M instantiations); giving each leaf an explicit consumed-type seed and combining with `compose`
made those leaves **~13× cheaper to check** (924.8 ms → 68.7 ms). Declare the seed as an explicit interface —
`Pick<FullContainer, 'a' | 'b'>` forces TypeScript to normalise the entire accumulated map and couples the
module to the whole graph. See the
[AI agent integration guide](./docs/ai-agent-guide.md) for the full pattern.
---
### Merge
You can merge containers to combine their resolvers and resolved values. Unlike `compose`, `merge` mutates and returns
the container it is called on.
- Dependencies from all containers are preserved.
- If several define the same key, the last one takes precedence at runtime (and any value the
replaced resolver had already produced is evicted). Types intersect, so a key defined twice with
different types becomes `never`.
- Already resolved values are reused — not re-created.
```ts
const containerA = new DIContainer().add('a', () => '1').add('bar', () => new Bar());
const containerB = new DIContainer().add('b', () => 'b').add('buzz', () => new Buzz('buzz'));
const finalContainer = containerA.merge(containerB);
console.log(finalContainer.a); // "1"
console.log(finalContainer.b); // "b"
console.log(finalContainer.bar instanceof Bar); // true
console.log(finalContainer.buzz.name); // "buzz"
```
`merge` accepts several containers at once, which avoids a long chain of merges:
```ts
const finalContainer = base.merge(repositories, services, controllers);
```
---
### Clone
Use `.clone()` to create a new container that shares resolvers and already resolved values with the original.
This is useful for creating isolated execution contexts while preserving the base setup.
```ts
const containerA = new DIContainer()
.add('a', () => '1')
.add('bar', () => new Bar())
.add('buzz', () => new Buzz('buzz'));
const containerB = containerA.clone();
console.log(containerB.a); // "1"
console.log(containerB.bar instanceof Bar); // true
console.log(containerB.buzz.name); // "buzz"
```
---
### Naming your container type
Consumer files usually want to refer to the built container by name:
```ts
export type AppDIContainer = ReturnType<typeof configureDI>;
```
That works, but TypeScript still expands it in diagnostics, so a container with a few hundred dependencies produces
errors and hovers like `IDIContainer<{ a: string; } & { b: number; } & … }>`. Wrapping it in `SealedContainer` creates a
fresh alias that TypeScript prints by name instead:
```ts
import { type SealedContainer } from 'rsdi';
export type AppDIContainer = SealedContainer<ReturnType<typeof configureDI>>;
// error messages and hovers now say `AppDIContainer`
export function configureRouter(app: core.Express, container: AppDIContainer) {
const { userController } = container; // still fully typed
}
```
The dependency types are unchanged — `container.userController` resolves exactly as before, and the container stays
chainable.
This is purely for readability. Referring to a container from another file is already cheap: in a 400-dependency graph,
50 consumer files add only ~2.6K type instantiations each, and naming the type costs about 3% _more_, not less. Reach
for it when your error messages get unreadable, not to speed up compilation — for that, see [Compose](#compose).
---
### Other methods
- **`.get(name)`** — resolve a dependency by name. Equivalent to property access (`container.foo`). Throws
`DependencyIsMissingError` if the name isn't registered.
- **`.has(name)`** — returns `true` if a resolver is registered under `name` (whether or not it has been resolved yet).
- **`.hasResolvedDependency(name)`** — returns `true` only if the dependency has already been resolved and cached.
- **`.update(name, resolver)`** — replace an existing dependency's resolver (see [How to use](#how-to-use)). Unlike
`.add()`, it expects the name to already exist.
```typescript
const container = new DIContainer().add('bar', () => new Bar());
container.has('bar'); // true
container.hasResolvedDependency('bar'); // false — not resolved yet
container.get('bar');
container.hasResolvedDependency('bar'); // true — now cached
```
---
## Further reading
- **[AI agent integration guide](./docs/ai-agent-guide.md)** — one page an AI coding agent can read
before wiring RSDI into a project: API reference, the mistakes that fail to compile, how to
structure a large container, and how to decode each error.
- [Async factory resolvers](./docs/async_factory_resolver.md) — why factories are synchronous, and
how to handle resources that need `await`.
- [DI container vs context](./docs/context_vs_container.md) — why the container stays at the
composition root instead of being passed through your app.
- [Strict types](./docs/strict_types.md) — what the compiler catches for you.
- [Type-performance plan](./docs/type-performance-plan.md) — measurements behind the O(N²) chain
cost and the composition guidance, for contributors.
- [Reading `pnpm bench:types`](./docs/type-benchmarks.md) — how to interpret the type-cost gate,
for contributors.