Skip to content

mkizka/gyaku

Repository files navigation

@gyaku/di

@gyaku/di

Gyaku (逆, "inversion") is a tiny, modern DI container for TypeScript, built around await using.

Why gyaku?

  • Type-safe registry chain
  • No decorators, no reflect-metadata
  • Sync and async factories under one API
  • Unknown deps and duplicate keys caught at compile time
  • Parallel resolution along the dependency graph
  • Parallel, graph-aware disposal via await using
  • Auto-cleanup of partially built services on failure
  • Symbol.asyncDispose and Symbol.dispose both honored

Install

Requires Node.js 24+.

npm install @gyaku/di

Usage

import { createRegistry } from "@gyaku/di";

const createGreeter = ({ name }: { name: string }) => ({
  say: () => console.log(`hello, ${name}`),
});

const registry = createRegistry()
  .value("name", "gyaku")
  .service("greeter", ["name"], createGreeter);

await using services = await registry.resolve();
services.greeter.say();
// hello, gyaku

Runnable examples: examples/.

Using an AI coding agent? Install the gyaku skill with npx skills:

npx skills add mkizka/gyaku

API

createRegistry()

Returns an empty, immutable registry.

.value(key, instance)

Registers a pre-built value, keeping its type as-is.

createRegistry().value("config", { port: 3000 });

.service(key, factory) / .service(key, deps, factory)

Registers a sync or async factory that receives only the dependencies listed in deps.

const registry = createRegistry()
  .value("config", { port: 3000 })
  .service("logger", createLogger)
  .service("server", ["config", "logger"], createServer);

.replaceService(key, factory) / .replaceService(key, deps, factory)

Swaps a registered factory in place, following the same rules as .service — deps can differ from the original registration. The replacement's return type must still satisfy the original contract.

const testRegistry = productionRegistry.replaceService(
  "db",
  ["config"], // can differ from the original "db" registration's deps
  createStubDb,
);

.replaceValue(key, instance)

Swaps a registered service with a pre-built instance, keeping the original return type.

const testRegistry = productionRegistry.replaceValue("db", stubDb);

resolve()

Resolves the graph and returns Promise<Services & AsyncDisposable>; factories run in parallel, await using disposes in reverse along the graph, and any failure auto-disposes what was already created.

Errors

All errors extend GyakuError.

  • RegistryError — invalid argument to .service / .value / .replaceService / .replaceValue.
  • ResolveError.resolve() failed. errors mixes ServiceFactoryError and ServiceDisposeError.
  • DisposeErrorSymbol.asyncDispose failed. errors is ServiceDisposeError[].

Each inner error has .key (the service that failed) and .cause (the original throw).

import { ResolveError, ServiceFactoryError } from "@gyaku/di";

try {
  await using services = await registry.resolve();
} catch (error) {
  if (error instanceof ResolveError) {
    for (const e of error.errors) {
      console.error(e.key, e.cause);
    }
  }
}

Notes

  • Re-registering a key throws; use .replaceService / .replaceValue to replace.
  • then is reserved (would make the services object look thenable).
  • Services object has a null prototype, so keys like __proto__ are safe.

Migration helpers

gyaku's first-class factory is a function taking a single deps object, ({ logger, db }) => .... These helpers adapt code that doesn't fit that shape — positional functions and classes — without rewriting it.

asFunctionArgs(fn)

Wraps a function that takes positional arguments. deps lists those parameters in order.

const createRepo = (logger: Logger, db: Db) => ({
  find: (sql: string) => db.query(sql),
});

createRegistry().service("repo", ["logger", "db"], asFunctionArgs(createRepo));

asClass(Class)

Wraps a class whose constructor takes a single deps object, so you skip the (deps) => new Foo(deps) wrapper. Fully type-safe.

class Greeter {
  constructor(private deps: { logger: Logger }) {}
}

createRegistry().service("greeter", ["logger"], asClass(Greeter));

asClassArgs(Class)

Like asFunctionArgs, but for a class with a positional constructor. deps lists the constructor parameters in order.

class Greeter {
  constructor(logger: Logger, db: Db) {}
}

createRegistry().service("greeter", ["logger", "db"], asClassArgs(Greeter));

Pinning to an interface

asClass<Interface>()(Class) registers the service as Interface instead of the concrete class (asClassArgs supports the same form). This matters for .replaceService / .replaceValue: a replacement must match the registered type, and matching a concrete class down to its #private fields is usually impossible. Pin to an interface and a stub only has to satisfy that interface.

interface UserRepository {
  find(id: number): Promise<User | undefined>;
}

class UserRepositoryImpl implements UserRepository {
  constructor(private deps: { db: Db }) {}
  find(id: number) {
    return this.deps.db.findUser(id);
  }
}

const registry = createRegistry()
  .service("db", createDb)
  // pinned to UserRepository, not UserRepositoryImpl
  .service(
    "userRepository",
    ["db"],
    asClass<UserRepository>()(UserRepositoryImpl),
  );

// the stub only has to satisfy UserRepository
const testRegistry = registry.replaceValue("userRepository", {
  find: async (id) => ({ id, name: "stub" }),
});

License

MIT

About

Tiny, modern DI container for TypeScript, built around `await using`

Topics

Resources

Stars

Watchers

Forks

Releases

Contributors

Languages