Documents injecute 1.x · Migrating from 0.x
Skip to content

Custom registration ​

injecute registers services with explicit add* calls. When you want services to be found instead (files in a directory, classes with decorators, a manifest, a list of plugins), build that on top of the public API. There are two places to plug it in:

You want toUseTypes
register services you can find when the app startsa module applied with extend()declared once by the module, then inferred like any other
answer for keys nobody can list in advancea middleware added with use()not in the container's type: every get() needs a cast

Use a module by default. It finds what to register and calls addSingleton() or addTransient(). From then on these are ordinary registrations: they are created lazily, cached, released by dispose(), shared with forks and replaced in isolated forks, and has(), preload() and buildServicesGraph() see them.

The compiler can't see what is found at runtime, so the module declares what it adds: it returns a ServiceRegistry<Requires, Services>. How much of that declaration the compiler can check depends on the approach. The examples below show both ends, and how generating the wiring from files gets the compiler's full checks back.

Services from files ​

Each file in services/ is a service: the file name is the key, the default export is the factory and dependencies lists the keys the factory receives, in order.

ts
// One service per file: the file name is the key, the default export the factory.
export default function createClock() {
  return { now: () => new Date(0) };
}
ts
import type createClock from './clock.ts';

// Keys of the services the factory receives, in order.
export const dependencies = ['config', 'clock'];

export default function createGreeter(
  config: { greeting: string },
  clock: ReturnType<typeof createClock>,
) {
  return {
    greet: (name: string) =>
      `${config.greeting}, ${name} (${clock.now().toISOString()})`,
  };
}

A loader imports the files, and a module registers them:

ts
/** A file in the services directory: the file name is the key, the default export the factory. */
interface ServiceFile {
  default: (...args: any[]) => unknown;
  dependencies?: string[];
}

type ServiceOf<F extends ServiceFile> = ReturnType<F['default']>;

/** Imports every service file in `directory`. */
async function loadServiceFiles(directory: URL) {
  const names = (await readdir(directory)).filter(
    (name) => /\.[jt]s$/.test(name) && !name.endsWith('.d.ts'),
  );
  return Promise.all(
    names.map(async (name) => ({
      key: name.replace(/\.[jt]s$/, ''),
      file: (await import(new URL(name, directory).href)) as ServiceFile,
    })),
  );
}

/**
 * A module that registers the loaded files as singletons. The compiler can't see the directory, so
 * `Services` declares what the files provide.
 */
function addServiceFiles<Services extends object, Requires extends object = {}>(
  files: { key: string; file: ServiceFile }[],
) {
  return (
    registry: ServiceRegistry<Requires>,
  ): ServiceRegistry<Requires, Services> => {
    const untyped = registry as unknown as ServiceRegistry<any>;
    for (const { key, file } of files) {
      untyped.addSingleton(key, file.default, file.dependencies ?? []);
    }
    return registry as unknown as ServiceRegistry<Requires, Services>;
  };
}

The app declares the services from the files' own types and applies the module:

ts
// Taken from the files' own types: a changed factory changes the service type here.
type Services = {
  clock: ServiceOf<typeof import('./services/clock.ts')>;
  greeter: ServiceOf<typeof import('./services/greeter.ts')>;
};

const files = await loadServiceFiles(new URL('./services/', import.meta.url));

const app = new DIContainer()
  .addInstance('config', { greeting: 'Hello' })
  .extend(addServiceFiles<Services, { config: { greeting: string } }>(files));

app.get('greeter').greet('Ada'); // "Hello, Ada (1970-01-01T00:00:00.000Z)"
  • Checked: the type of each service (taken from its file, so a changed factory changes it), and that the container has what the files need (Requires) when you call extend().
  • Not checked: that a file exists for every declared key, and that dependencies match the factory's parameters. A missing file fails with INJECUTE_NOT_REGISTERED when something resolves it. Check the wiring in a test to catch it before deploying, or generate the wiring and the compiler checks both.
  • extend() is synchronous: load the files first (await), then apply the module.
  • Bundled apps and browsers can't read a directory. With Vite, import.meta.glob('./services/*.ts', { eager: true }) returns the same modules at build time.

Generating the wiring from files ​

Instead of loading the files when the app starts, a script can write the wiring from them: plain addSingleton() calls, as you would write them by hand. The compiler then checks the generated file like any other wiring.

For files in your project, generate the wiring. Load files at runtime only when they aren't known before the app starts, such as plugins installed later.

The script reads the same files as the loader above: the file name is the key, the default export the factory, and dependencies the keys it receives. It orders the registrations so each service comes after its dependencies, and keys that no file provides become the module's requirements:

ts
/** A file in the services directory: the file name is the key, the default export the factory. */
interface ServiceFile {
  default: (...args: any[]) => unknown;
  dependencies?: readonly string[];
}

/**
 * Writes a wiring file with one `addSingleton()` per file in `directory`, dependencies first. Keys
 * that no file provides become the module's requirements, typed from the factory parameters that
 * receive them.
 */
export async function generateWiring(
  directory: URL,
  output: URL,
): Promise<string> {
  const names = (await readdir(directory))
    .filter((name) => /\.[jt]s$/.test(name) && !name.endsWith('.d.ts'))
    .sort();
  const services = new Map<string, { path: string; dependencies: string[] }>();
  for (const name of names) {
    const url = new URL(name, directory);
    const file = (await import(url.href)) as ServiceFile;
    let path = relative(dirname(fileURLToPath(output)), fileURLToPath(url));
    if (!path.startsWith('.')) path = `./${path}`;
    const key = name.replace(/\.[jt]s$/, '');
    services.set(key, { path, dependencies: [...(file.dependencies ?? [])] });
  }

  // A service is registered after the services it depends on, so each line type-checks.
  const order: string[] = [];
  const visiting = new Set<string>();
  const visit = (key: string, path: string[]) => {
    if (order.includes(key) || !services.has(key)) return;
    if (visiting.has(key))
      throw new Error(`Circular dependency: ${[...path, key].join(' -> ')}`);
    visiting.add(key);
    for (const dependency of services.get(key)!.dependencies)
      visit(dependency, [...path, key]);
    order.push(key);
  };
  for (const key of services.keys()) visit(key, []);

  // Keys no file provides: the container must have them, typed as the factory expects.
  const requires = new Map<string, string>();
  for (const key of order)
    services.get(key)!.dependencies.forEach((dependency, index) => {
      if (!services.has(dependency) && !requires.has(dependency))
        requires.set(dependency, `Parameters<typeof ${key}.default>[${index}]`);
    });

  const source = [
    `// Generated from ${relative(dirname(fileURLToPath(output)), fileURLToPath(directory))}/ by generate-wiring.ts. Don't edit: run the generator.`,
    `import type { ServiceRegistry } from 'injecute';`,
    ...order.map(
      (key) => `import * as ${key} from '${services.get(key)!.path}';`,
    ),
    ``,
    `export interface Requires {`,
    ...[...requires].map(([key, type]) => `  ${key}: ${type};`),
    `}`,
    ``,
    `export const addServices = (registry: ServiceRegistry<Requires>) =>`,
    `  registry`,
    ...order.map((key) => {
      const dependencies = services.get(key)!.dependencies;
      const list = dependencies.map((d) => `'${d}'`).join(', ');
      return `    .addSingleton('${key}', ${key}.default${list ? `, [${list}]` : ''})`;
    }),
  ];
  return `${source.join('\n')};\n`;
}

For the two files above, it writes:

ts
// Generated from ../services/ by generate-wiring.ts. Don't edit: run the generator.
import type { ServiceRegistry } from 'injecute';
import * as clock from '../services/clock.ts';
import * as greeter from '../services/greeter.ts';

export interface Requires {
  config: Parameters<typeof greeter.default>[0];
}

export const addServices = (registry: ServiceRegistry<Requires>) =>
  registry
    .addSingleton('clock', clock.default)
    .addSingleton('greeter', greeter.default, ['config', 'clock']);

The app applies it like any module, without declaring Services:

ts
import { DIContainer } from 'injecute';
import { addServices } from './services.generated.ts';

const app = new DIContainer()
  .addInstance('config', { greeting: 'Hello' })
  .extend(addServices);

app.get('greeter').greet('Ada'); // "Hello, Ada (1970-01-01T00:00:00.000Z)"
  • Checked: everything the compiler checks in hand-written wiring. dependencies that don't match the factory's parameters are an error at that service's addSingleton(). A dependency that no file and no registration provides is an error at extend(), and so is get() with a key that has no file.

  • Not checked: that the wiring file is up to date with the files. Run the script before type checking, or compare its output with the committed file in a test:

    ts
    it('the wiring file is up to date', async () => {
      const output = new URL('./services.generated.ts', import.meta.url);
      const source = await generateWiring(
        new URL('./services/', import.meta.url),
        output,
      );
      expect(source).toBe(await readFile(output, 'utf8'));
    });
  • The script imports the files to read dependencies: run it with a runtime that runs TypeScript, such as Node.js 22.18 or later, Deno or Bun. File names must be valid identifiers.

  • The wiring is ordinary imports, so bundlers and browsers need nothing extra.

Mistakes in the files are compile errors. Dependencies in the wrong order:

ts
export const dependencies = ['clock', 'config']; // the factory takes (config, clock)
text
services.generated.ts: Argument of type '(config: { greeting: string; }, clock: { now: () => Date; }) => …'
  is not assignable to parameter of type 'Factory<["clock", "config"], …>'.
    Types of parameters 'config' and 'args_0' are incompatible.

A dependency that no file provides and the app doesn't register:

ts
export const dependencies = ['config', 'clock', 'mailer']; // there is no services/mailer.ts
text
app.ts: Argument of type '(registry: ServiceRegistry<Requires>) => …' is not assignable to parameter
  of type '{ 'injecute: extension requires services that are not registered': "mailer"; }'.

Services from decorators ​

Standard decorators (TC39, TypeScript 5 and later without experimentalDecorators) don't emit the types of constructor parameters, so a decorator can't infer what a class needs. Each decorator names its key and its dependency keys, as addSingleton() does, and the compiler checks them against the class.

createDecorators() is about 50 lines. Copy it into your project and adapt it:

ts
type Key<S> = Extract<keyof S, string>;
type Resolved<S, D extends unknown[]> = { [I in keyof D]: S[D[I] & keyof S] };
type Lifetime = 'addSingleton' | 'addTransient';
// The keys are checked by the decorators; registering them needs no types.
type UntypedRegistry = Record<
  Lifetime,
  (key: string, target: unknown, dependencies: string[]) => unknown
>;

/**
 * Standard (TC39) class decorators that register classes in a list; the returned function is a
 * module that adds them to a container with `extend()`.
 *
 * `Services` is what the decorated classes provide, `Requires` what they need from the container.
 */
export function createDecorators<
  Services extends object,
  Requires extends object = {},
>() {
  type All = Services & Requires;
  const registrations: ((registry: UntypedRegistry) => void)[] = [];

  const decorator =
    (lifetime: Lifetime) =>
    <K extends Key<Services>, const D extends Key<All>[] = []>(
      key: K,
      dependencies?: D,
    ) =>
    // The class must produce `Services[K]` from the services listed in `dependencies`.
    (
      target: new (...args: Resolved<All, D>) => Services[K],
      _context: ClassDecoratorContext,
    ) => {
      registrations.push((registry) =>
        registry[lifetime](key, target, dependencies ?? []),
      );
    };

  const addDecorated = (
    registry: ServiceRegistry<Requires>,
  ): ServiceRegistry<Requires, Services> => {
    for (const register of registrations)
      register(registry as unknown as UntypedRegistry);
    return registry as unknown as ServiceRegistry<Requires, Services>;
  };

  return Object.assign(addDecorated, {
    singleton: decorator('addSingleton'),
    transient: decorator('addTransient'),
  });
}

Declare the services once, decorate the classes and apply the decorators as a module:

ts
// The services the decorated classes provide, and what they need from the container.
type Services = { clock: Clock; greeter: Greeter };
type Requires = { config: Config };

const services = createDecorators<Services, Requires>();

@services.singleton('clock')
class Clock {
  now() {
    return new Date(0);
  }
}

@services.singleton('greeter', ['config', 'clock'])
class Greeter {
  constructor(
    private config: Config,
    private clock: Clock,
  ) {}

  greet(name: string) {
    return `${this.config.greeting}, ${name} (${this.clock.now().toISOString()})`;
  }
}

const app = new DIContainer()
  .addInstance('config', { greeting: 'Hello' })
  .extend(services); // DIContainer<{ config: Config } & Services>

app.get('greeter').greet('Ada'); // "Hello, Ada (1970-01-01T00:00:00.000Z)"

Open in the playground: the example is one file, with createDecorators() at the top.

  • Checked: the key is one of Services. Each dependency key is in Services or Requires. The constructor accepts those services in that order, and its instances are the declared service. The container has Requires when you call extend(), and has Services afterwards.
  • Not checked: that every key of Services has a decorated class. A decorator runs when its file is evaluated, so a class whose file nobody imports is never registered, and resolving it fails with INJECUTE_NOT_REGISTERED. Import every decorated file from the wiring file that calls extend(), and check the wiring in a test.
  • Two classes under the same key fail with INJECUTE_ALREADY_REGISTERED at extend().
  • The decorators only collect registrations. Instances live in the container that applies them, so every container, fork and test gets its own.

Mistakes are compile errors at the decorator:

ts
@services.singleton('mailer') // not a key of Services
class Mailer {}

@services.singleton('greeter', ['clock']) // the constructor needs (Config, Clock)
class Greeter {
  constructor(config: Config, clock: Clock) {}
}
text
Argument of type '"mailer"' is not assignable to parameter of type 'keyof Services'.

Unable to resolve signature of class decorator when called as an expression.
  Argument of type 'typeof Greeter' is not assignable to parameter of type 'new (args_0: Clock) => Greeter'.

What this costs:

  • The wiring moves from one file into the classes, and the classes import the decorators. You lose "readable in one place" (principles 1 and 2). The classes themselves stay plain: a test still creates them with new Greeter(config, clock).
  • Standard decorators must be compiled. tsc compiles them. Node.js (including its type stripping) doesn't run them yet, and Vite 8 and Vitest 5 compile only legacy decorators. In Vite or Vitest, compile files that use decorators with TypeScript first:
ts
import ts from 'typescript';
import { defineConfig, type Plugin } from 'vitest/config';

// Vite's transform (Oxc) only lowers legacy decorators, and Node.js doesn't run standard ones yet:
// files with standard decorators go through TypeScript first.
const standardDecorators: Plugin = {
  name: 'standard-decorators',
  enforce: 'pre',
  transform(code, id) {
    if (!id.endsWith('.ts') || !/^\s*@\w/m.test(code)) return;
    const { outputText, sourceMapText } = ts.transpileModule(code, {
      fileName: id,
      compilerOptions: {
        target: ts.ScriptTarget.ES2022,
        module: ts.ModuleKind.ESNext,
        sourceMap: true,
      },
    });
    return { code: outputText, map: sourceMapText };
  },
};

Testing ​

Services registered by a module are ordinary registrations, however the module found them. Tests work as described in Testing: replace a dependency in an isolated fork, and everything resolved through the fork uses the replacement.

ts
// The decorated classes are ordinary registrations: an isolated fork replaces the clock for the test.
await using testContainer = app
  .fork({ isolated: true })
  .addInstance(
    'clock',
    { now: () => new Date('2030-01-01') },
    { replace: true },
  );

const greeting = testContainer.get('greeter').greet('Ada'); // "Hello, Ada (2030-01-01T00:00:00.000Z)"
app.get('greeter').greet('Ada'); // "Hello, Ada (1970-01-01T00:00:00.000Z)": the app is untouched

The same works for services registered from files.

Checking the wiring ​

The compiler checks what you declare in Services, but not that every declared service is actually registered. List the keys in an object that satisfies Record<keyof Services, true>: the compiler then checks the list against Services, so a declared service can't be left out. Pass the list to checkWiring() in a test:

ts
// The compiler can't see the directory, but it checks this list against `Services`: a key missing
// here, or one that `Services` doesn't have, is a compile error.
const declared = { clock: true, greeter: true } satisfies Record<
  keyof Services,
  true
>;

/** Reports every declared service without a file, and every dependency key that nothing registers. */
async function checkServiceFiles(container: ServiceProvider<Services>) {
  await checkWiring(container, {
    keys: Object.keys(declared) as (keyof Services)[],
  });
}

await checkServiceFiles(app);
  • A declared service without a file or decorated class is reported as not registered.
  • A dependency key that nothing registers (a typo in dependencies) is reported with every service that needs it. Neither check runs a factory.
  • Dependencies in the wrong order are caught only if the factory fails with them: pass resolve: true to run every factory (on an isolated fork with fakes). With files, nothing compares dependencies with the factory's parameters; with the decorators, the compiler does.
  • The same check works for decorated classes: list the keys of the Services you passed to createDecorators().

Keys nobody can list: a middleware ​

When the keys come from outside and can't be listed at startup, such as environment variables or remote settings looked up by name, a middleware can answer for them:

ts
// Resolve `env.*` keys from the environment when nothing is registered under them.
const env: Record<string, string> = { PORT: '8080' };

const app = new DIContainer().use((key, next) => {
  const value = next();
  if (
    value === undefined &&
    typeof key === 'string' &&
    key.startsWith('env.')
  ) {
    return env[key.slice('env.'.length)];
  }
  return value;
});

// Keys a middleware provides are not in the container's type; declare them where you use them:
const port = app.get('env.PORT' as never) as string; // "8080"

A middleware is the right tool only when the keys really are unknown up front:

  • The keys aren't in the container's type, so every get() needs a cast. Wrap the lookup in a typed function instead of casting at every call site.
  • It runs on every get() and caches nothing. Cache values yourself if they are expensive.
  • The values aren't registrations: has(), preload() and buildServicesGraph() don't know them, and dispose() doesn't release them.

If you can list the keys when the app starts, even by computing them, register them in a module instead. For example, read the environment once and call addInstance() for every variable. See Middlewares and events for what else middlewares do.

Released under the MIT License.