npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

di-craft

v0.0.20

Published

A tiny, type-safe dependency injection container for TypeScript

Readme

Contents

Quick start

Declare typed tokens, describe how each one is built with a provider, then create a container and resolve from it. Dependencies are wired explicitly through the deps map and resolved for you.

import {
  createContainer,
  createToken,
  provideFactory,
  provideValue,
  type Provider,
} from "di-craft";

const CONFIG = createToken<Config>("config");
const LOGGER = createToken<Logger>("logger");
const USERS = createToken<UserService>("users");

const providers: Provider[] = [
  provideValue(CONFIG, loadConfig()),
  provideFactory(LOGGER, {
    deps: { config: CONFIG },
    useFactory: ({ config }) => new Logger(config.level),
  }),
  provideFactory(USERS, {
    deps: { logger: LOGGER },
    useFactory: ({ logger }) => new UserService(logger),
  }),
];

const container = createContainer(providers);

const users = container.get(USERS); // UserService, fully typed

Philosophy

Dependency injection without hidden magic — no reflect-metadata, no runtime type guessing, and no framework coupling. You work with just tokens, providers, a container, scopes, and cycle detection. Standard JavaScript decorators are available as optional sugar for class providers, but they still use explicit tokens.

Features

  • Zero runtime dependencies
  • Type-safe tokens and factories
  • Optional @Injectable annotation for class providers
  • Optional dependencies via optional()
  • Singleton, transient, and scoped lifetimes
  • Hierarchical child containers
  • Deterministic disposal with onDispose hooks
  • Circular dependency detection
  • Tree-shakable, tiny bundle size
  • ESM-only, ships with TypeScript declarations

Install

bun add di-craft
npm install di-craft
pnpm add di-craft
yarn add di-craft

Requires Node.js >= 20. This package is ESM-only — import it with import; CommonJS code can load it with a dynamic import().

Core concepts

Tokens

A token is a unique, type-carrying key. Identity is based on an internal symbol, not on the name — two tokens with the same name are still different.

const PORT = createToken<number>("port");

PORT.name; // "port" — used only for error messages

The type argument flows everywhere: providers must produce a matching value, and container.get(PORT) returns number.

Providers

A provider tells the container how to produce the value for a token.

provideValue — register an existing value:

provideValue(PORT, 3000);

provideFactory — build the value lazily, with optional dependencies and scope:

provideFactory(HTTP, {
  deps: { config: CONFIG }, // optional, keyed map of tokens
  scope: "singleton", // optional, defaults to "singleton"
  useFactory: ({ config }) => new HttpClient(config.apiUrl),
});

The keys in deps become the keys of the object passed to useFactory, each resolved to its token's type.

Annotation-based class providers

If you write services as classes, you can attach provider metadata to the class with standard JavaScript decorators, then turn the class into a normal provider with provideInjectable.

@Injectable(options) marks a class as a provider for options.token. deps is an ordered list of constructor dependencies. scope and onDispose behave exactly like they do in provideFactory.

import {
  Injectable,
  Scopes,
  createContainer,
  createToken,
  optional,
  provideInjectable,
  provideValue,
} from "di-craft";

const CONFIG = createToken<Config>("config");
const LOGGER = createToken<Logger>("logger");
const USERS = createToken<UserService>("users");

@Injectable({
  token: USERS,
  deps: [LOGGER, optional(CONFIG)],
  scope: Scopes.Scoped,
})
class UserService {
  private readonly logger: Logger;
  private readonly config: Config | undefined;

  constructor(logger: Logger, config: Config | undefined) {
    this.logger = logger;
    this.config = config;
  }
}

const container = createContainer([
  provideValue(LOGGER, new Logger()),
  provideInjectable(UserService),
]);

const users = container.get(USERS); // UserService

@Injectable is the only annotation needed for class injection. It produces a regular factory provider internally, so all existing container behavior still applies: optional dependencies, scopes, child containers, disposal hooks, overrides, and cycle detection.

di-craft does not use reflect-metadata or parameter decorators. Constructor types are erased by JavaScript at runtime, so dependency tokens stay explicit instead of being guessed from TypeScript types.

Optional dependencies

Wrap a token with optional to mark a dependency as not required. When no provider for it is registered anywhere in the container chain, the factory receives undefined instead of the resolution throwing MissingProviderError. The inferred type is widened to T | undefined, so TypeScript forces you to handle the absent case:

import { optional, provideFactory } from "di-craft";

provideFactory(USERS, {
  deps: { logger: optional(LOGGER) }, // LOGGER may or may not be registered
  useFactory: ({ logger }) => {
    logger?.info("creating users service"); // logger: Logger | undefined
    return new UsersService();
  },
});

The same descriptor works at the top level — optional can be passed anywhere a dependency is accepted, including container.get:

const logger = container.get(optional(LOGGER)); // Logger | undefined

Optional only affects the token itself: if a provider is registered, it is resolved normally and its own errors (cycles, missing nested deps) still surface.

Container

The container holds your providers and resolves values on demand. Create one from a list of providers (all optional), then add more, check, resolve, and dispose:

  • register(provider, options?) — add a provider at any time.
  • has(token) — whether a provider for the token is registered.
  • get(token) — resolve the value, building and caching it as its scope dictates. Accepts optional(token) to get undefined instead of throwing when absent.
  • dispose() — run onDispose hooks and release tracked instances owned by this container.
const container = createContainer(providers); // providers are optional

container.register(provideValue(PORT, 3000)); // register more at any time
container.has(PORT); // true
container.get(PORT); // 3000
await container.dispose(); // clears tracked instances and awaits disposal hooks

Registering the same token twice throws DuplicateProviderError. To replace an existing provider on purpose (handy for tests, mocks, and environment-specific overrides), pass { allowOverride: true }:

container.register(provideValue(API, fakeApi), { allowOverride: true });

Overriding a token whose value was already resolved as a singleton drops the cached instance, so the next get rebuilds it from the new provider. If that resolved instance has an onDispose hook, the override throws InvalidProviderError instead of silently dropping it — call dispose() first so the resource is released, then register the replacement.

Scopes

| Scope | Behavior | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | singleton (default) | The factory runs once; the same instance is returned every time. | | transient | The factory runs on every get, producing a fresh instance. | | scoped | One instance per container. In a child container each child gets its own instance, while the provider can still be declared once on the parent. |

Use the Scopes helper for autocompletion, or pass the plain string — both work:

import { Scopes, provideFactory } from "di-craft";

provideFactory(ID, {
  scope: Scopes.Transient, // or scope: "transient"
  useFactory: () => crypto.randomUUID(),
});

container.get(ID) !== container.get(ID); // true

A provider may only depend on dependencies that live at least as long as itself, so a longer-lived instance never captures a shorter-lived one. A transient may depend on anything; a scoped may depend on scoped or singleton; a singleton may depend only on singletons (and values). Violating this throws InvalidProviderError at resolution. A transient that depends on a singleton, for example, reuses the shared singleton instance.

Disposal

Factory providers can declare an onDispose hook to release resources (database pools, sockets, timers, subscriptions). Calling container.dispose() runs the hooks for every resolved cached instance owned by that container and releases the container's tracked instances:

const DB = createToken<Pool>("db");

const container = createContainer([
  provideFactory(DB, {
    useFactory: () => createPool(url),
    onDispose: (pool) => pool.end(), // may be sync or async
  }),
]);

container.get(DB);

await container.dispose(); // clears tracked instances and awaits disposal hooks

Details:

  • Hooks run in reverse creation order (dependents before their dependencies).
  • dispose() returns a promise and awaits async hooks.
  • Instances are removed from the cache before hooks run, making disposal idempotent and re-entrancy safe.
  • Only resolved cached instances owned by that container are disposed: singletons owned by that container and scoped instances created for that container. Transient and never-resolved instances are not tracked.
  • onDispose is only meaningful for cached instances. Declaring it on a transient provider throws InvalidProviderError, since transient instances are never tracked and the hook could never run.

Child containers

createChildContainer(parent, providers?) creates a child that inherits everything from its parent but can add or override providers locally. This is the typical pattern for per-request isolation on a server: shared services live on the root, request-specific values live on a short-lived child.

const root = createContainer([
  provideFactory(LOGGER, { useFactory: () => console }), // singleton, shared
  provideFactory(HANDLER, {
    scope: Scopes.Scoped, // one instance per child
    deps: { request: REQUEST },
    useFactory: ({ request }) => createHandler(request),
  }),
]);

function handle(request: Request) {
  const child = createChildContainer(root, [provideValue(REQUEST, request)]);

  child.get(LOGGER); // same logger as the root and every other child
  child.get(HANDLER); // a fresh handler, unique to this child

  return child.dispose(); // release only this child's instances
}

How resolution works across the chain:

  • A token is looked up in the child first, then walks up to the parent.
  • singleton is cached on the container that owns the provider, so it is shared by the whole subtree.
  • scoped is cached on the requesting child, so each child gets its own instance — even when the provider is declared once on the parent.
  • A scoped provider resolves its dependencies from the requesting child, so it can depend on values registered only in that child (like REQUEST).
  • dispose() only releases the container it is called on; it does not cascade to parents or children.

Cycle detection

If providers form a dependency cycle, resolution throws CircularDependencyError with the full path instead of overflowing the stack.

// A -> B -> A
container.get(A); // throws: Circular dependency detected: A -> B -> A

Async dependencies

di-craft resolves synchronously by design — there is no getAsync, and async never colors the rest of your graph. Asynchronous values are handled with one of two patterns, which together cover the vast majority of cases.

Resolve first, then register. Do the async work at your composition root and register the resolved value. Simplest and most common:

const db = await connectDatabase(config);
container.register(provideValue(DB, db));

Promise as value (lazy). Register a factory that returns a promise. The container caches it like any other singleton, so the async work runs once and every consumer awaits the same promise:

const POOL = createToken<Promise<Pool>>("pool");

container.register(provideFactory(POOL, { useFactory: () => createPool() }));

const pool = await container.get(POOL);

A factory that depends on POOL receives the promise and awaits it itself:

provideFactory(USERS, {
  deps: { pool: POOL },
  useFactory: async ({ pool }) => new UsersRepo(await pool),
});
// USERS is now Token<Promise<UsersRepo>> — consumers await it too.

When using Promise<T> as the token value, disposal hooks receive the promise itself. Await it inside onDispose if cleanup needs the resolved value:

const POOL = createToken<Promise<Pool>>("pool");

provideFactory(POOL, {
  useFactory: () => createPool(),
  onDispose: async (poolPromise) => {
    const pool = await poolPromise;
    await pool.end();
  },
});

Adapters

Adapters are optional framework integrations built around the core container. They live behind subpath exports, so the root import stays framework-agnostic:

import { createContainer } from "di-craft"; // core only

Next.js App Router

The Next adapter helps connect di-craft to the App Router request lifecycle without making React or Next.js part of the core import.

Use di-craft/next/server from a server-only composition file. Pass React's cache function so React/Next owns request memoization while di-craft owns only the dependency graph:

// app/di.server.ts
import "server-only";
import { cache } from "react";
import { provideValue } from "di-craft";
import { createNextDi } from "di-craft/next/server";

export const {
  getRequestContainer,
  getRootContainer,
  runWithRequestContainer,
} = createNextDi({
  cache,
  providers,
  requestProviders: () => [
    provideValue(REQUEST_ID, crypto.randomUUID()),
  ],
});

Then resolve dependencies in Server Components at the composition edge:

import { getRequestContainer } from "./di.server";

export default async function Page() {
  const users = getRequestContainer().get(USERS_SERVICE);

  return <UsersView users={await users.list()} />;
}

Next.js does not expose a general "RSC render is finished" hook, so the adapter does not pretend it can automatically dispose a cached Server Component request container. If you own the lifecycle, for example in a Route Handler, Server Action, test, or job, use runWithRequestContainer. It creates a fresh child container and disposes it in a finally block:

import { runWithRequestContainer } from "./di.server";

export async function GET() {
  return runWithRequestContainer({
    run: async (container) => {
      const users = await container.get(USERS_SERVICE).list();

      return Response.json(users);
    },
  });
}

State hydration is explicit. The server reads serializable snapshots with di-craft/next/server; the client restores them with di-craft/next/client. The DI container itself is never hydrated.

Import boundary-specific runtime helpers from their own subpath:

  • di-craft/next/servercreateNextDi, dehydrate, server-only adapter types.
  • di-craft/next/clienthydrate, client-boundary hydration types.
  • Shared hydration contracts like Hydratable, HydrationSchema, and HydrationSnapshot are exported from both subpaths for convenience.
import {
  dehydrate,
  type Hydratable,
  type HydrationSchema,
} from "di-craft/next/server";

class UserState implements Hydratable<UserSnapshot> {
  dehydrate(): UserSnapshot {
    return { users: this.users };
  }

  hydrate(snapshot: UserSnapshot): void {
    this.users = snapshot.users;
  }
}

const hydration = {
  user: USER_STATE,
} satisfies HydrationSchema;
const snapshot = dehydrate({
  container: getRequestContainer(),
  schema: hydration,
});
"use client";

import { hydrate } from "di-craft/next/client";

hydrate({
  container: clientContainer,
  schema: hydration,
  snapshot,
});

Dependency injection vs service location

di-craft is built for dependency injection: dependencies are declared up front and handed to your code. The opposite is service location, where code reaches into a container at runtime to pull what it needs, hiding its real dependencies.

Two habits keep usage canonical: call container.get() only at the composition root (entrypoint, framework hooks, route handlers), and never pass the container into your classes or functions. di-craft enforces the key half for you — a factory only ever receives its declared deps, never the container — so a provider physically cannot locate arbitrary services.

// Dependency injection — deps are explicit, the class never sees the container.
provideFactory(USERS, {
  deps: { repo: REPO, logger: LOGGER },
  useFactory: ({ repo, logger }) => new UserService(repo, logger),
});

const users = container.get(USERS); // resolved at the root, then injected down
// Service location (anti-pattern) — the container is smuggled into domain code.
class UserService {
  constructor(private container: Container) {}

  list() {
    const repo = this.container.get(REPO); // hidden, runtime-only dependency
  }
}

The second form compiles, but it hides dependencies and defeats DI. No runtime flag can forbid it — get() is the same call the composition root relies on — so keep resolution at the edges by convention, or enforce it with a lint rule that allows .get() only in your composition-root files.

Error handling

All errors extend the shared DiError base class, so you can catch any container error with a single check:

import { DiError, MissingProviderError } from "di-craft";

try {
  container.get(SOME_TOKEN);
} catch (error) {
  if (error instanceof MissingProviderError) {
    // a specific failure
  }

  if (error instanceof DiError) {
    // any di-craft error
  }
}

| Error | Thrown when | | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | MissingProviderError | A token is resolved but no provider is registered. | | DuplicateProviderError | A token is registered more than once. | | CircularDependencyError | Providers form a dependency cycle. | | InvalidDependencyError | A declared dependency token is missing/undefined. | | InvalidProviderError | A provider is misconfigured (onDispose on a transient, or a dependency with a shorter lifetime than its consumer) or an override would drop a live disposable instance. |

API reference

| Export | Description | | ------------------------------------------ | --------------------------------------------------------------------------- | | createToken<T>(name) | Create a unique, typed token. | | provideValue(token, value) | Provider that returns an existing value. | | provideFactory(token, options) | Provider that builds a value via a factory. | | @Injectable(options) | Mark a class as a token-backed injectable provider. | | provideInjectable(class) | Create a factory provider from an injectable class. | | optional(token) | Mark a dependency as optional (resolves to undefined when absent). | | createContainer(providers?) | Create a container, optionally seeded with providers. | | createChildContainer(parent, providers?) | Create a child container that inherits from parent. | | Scopes | Object of scope values (Scopes.Singleton, Scopes.Transient, Scopes.Scoped). |

Exported types: Container, Token, Provider, ValueProvider, FactoryProvider, Dependency, OptionalDependency, Scope, DisposeHook, RegisterOptions.

Exported errors: DiError, MissingProviderError, DuplicateProviderError, CircularDependencyError, InvalidDependencyError, InvalidProviderError.

Subpath exports:

| Export | Description | | --------------------------- | ------------------------------------------------------- | | di-craft/next/server | Next.js server adapter for request-scoped containers. | | di-craft/next/client | Client-boundary helpers for restoring state snapshots. |

License

MIT