Skip to content

Quick start ​

This walks through a single-package setup in library mode: four steps, first success in a few minutes. For the shape of the whole system before you start, read How it fits together; for adding this to a codebase that already exists, read Adopting on an existing codebase afterwards.

1. Create factories ​

Write plain factory functions. Two requirements: the export name starts with build, and the function declares an explicit return type annotation naming its contract. Each factory's first parameter is a named local deps type describing what it consumes.

(Prefer classes? An exported class with an implements clause is a registration unit too, and everything below applies to it unchanged — see Class registration.)

ts
// src/services/buildUserRepository.ts
export type UserRepository = {
  findById: (id: string) => Promise<User | undefined>;
};

export const buildUserRepository = (): UserRepository => ({
  findById: async (id) => db.users.find(id),
});
ts
// src/services/buildUserService.ts
import type { UserRepository } from "./buildUserRepository.js";

export type UserService = {
  getUser: (id: string) => Promise<User | undefined>;
};

type UserServiceDeps = {
  userRepository: UserRepository;
};

export const buildUserService = ({
  userRepository,
}: UserServiceDeps): UserService => ({
  getUser: (id) => userRepository.findById(id),
});

The return annotation is what declares the contract. It is required — a build* export without one fails generation, listing every offender at once. Promise<T> is unwrapped, so an async factory annotates Promise<UserService> and supplies UserService. The contract must be a named type: an inline object literal or an anonymous union is an error, with guidance to name it.

The named-deps-type pattern is required too. Factories cannot destructure directly from IocGeneratedCradle, and inline object literals (({ foo, bar }: { foo: Foo; bar: Bar })) aren't allowed either — codegen will reject both. The rule is: the first parameter must be a named interface or type alias.

Three reasons:

  1. The cradle is generated from your factories' declarations. A factory declaring its inputs by referencing the cradle would be a chicken-and-egg loop — the cradle doesn't exist yet at the moment codegen reads the factory.

  2. The deps type is the factory's testable contract. Exporting type UserServiceDeps = { ... } means tests can import type { UserServiceDeps }, build a literal satisfying it, and call the factory directly with no container at all (see Testing below). Inline literals aren't importable — tests would have to reconstruct the same shape by hand in every file, and that drifts.

  3. The deps type is documentation. When someone opens the file, the named declaration sits at the top and says exactly what the factory consumes. Inline literals bury the contract inside the function signature, where it competes for attention with parameter names and the return type.

The cost is one extra line per factory. That's the deal.

Each property of that deps type declares which of five things it is: userRepository: UserRepository names a contract, so it resolves to whichever implementation of UserRepository is elected as the default. When you want one specific implementation rather than the elected one, you say so with Named<T>:

ts
import type { Named } from "ioc-manifest";

type UserServiceDeps = {
  userRepository: UserRepository;              // the elected default
  cachedUserRepository: Named<UserRepository>; // that implementation, pinned
};

With one implementation per contract you will not need the marker; it matters as soon as a contract has two. The other three things a property can be — a group, a scope-root opener, and an external the composing app supplies — are in the demand model.

2. Configure ​

Create ioc.config.ts at your package root or under src/:

ts
import { defineIocConfig } from "ioc-manifest";

export default defineIocConfig({
  discovery: {
    scanDirs: "src",
    generatedDir: "generated",
  },
});

That's the minimal config. The generator scans src/ for build* exports and writes output to generated/.

3. Generate ​

bash
npx ioc generate

Run this after changing factories or config. The generator prints a summary:

Generated generated/ioc-manifest.ts — 12 module factory(ies), 8 contract(s).

generate is the verb that decides. It does not only emit — it checks that what it read holds together, and a run that finds a hard error writes nothing at all, reporting every offender in one pass rather than failing on the first. That means a red run leaves the files from your last green run in place; ioc generate describes your sources, and ioc inspect describes those files. When the two disagree, the staleness banner says so.

You can also call generateManifest() programmatically if you need to integrate generation into a custom build script.

4. Bootstrap Awilix ​

ts
import { createContainer, InjectionMode } from "awilix";
import { registerIocFromManifest } from "ioc-manifest";
import { iocManifest } from "./generated/ioc-manifest.js";
import type { IocGeneratedCradle } from "./generated/ioc-registry.types.js";

const container = createContainer<IocGeneratedCradle>({
  injectionMode: InjectionMode.PROXY,
});

registerIocFromManifest(container, [iocManifest]);

// Fully typed — no 'any', no string guessing
const userService = container.resolve("userService");

Note that registerIocFromManifest takes an array of manifests, even when there's only one. The array is set-like — ordering is irrelevant, and the same input always produces the same registrations.

That's all you need for most single-package applications.

Where to go from here ​


Released under the MIT License.