UNPKG

rsdi

Version:

TypeScript dependency injection container. Strong types without decorators.

455 lines (333 loc) 16.7 kB
# RSDI - Simple & Strong-Type Dependency Injection Container [![npm version](https://img.shields.io/npm/v/rsdi.svg)](https://www.npmjs.com/package/rsdi) [![CI](https://github.com/radzserg/rsdi/actions/workflows/ci.yml/badge.svg)](https://github.com/radzserg/rsdi/actions/workflows/ci.yml) [![license](https://img.shields.io/npm/l/rsdi.svg)](./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) ![architecture](https://github.com/radzserg/rsdi/raw/main/docs/RSDI_architecture.jpg 'RSDI Architecture') 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. ![strict type](https://github.com/radzserg/rsdi/raw/main/docs/RSDI_types.png 'RSDI types') 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.