DEV Community

Cover image for The Dependency Graph Is the Type: Meet InferDI
Viacheslav Kabanov
Viacheslav Kabanov

Posted on

The Dependency Graph Is the Type: Meet InferDI

Most TypeScript dependency injection containers type services.

InferDI goes further: it types the dependency graph itself.

As you register services, the container's type evolves with the graph. TypeScript knows which services exist, what their dependencies resolve to, their lifetimes, whether they belong to the synchronous or asynchronous graph, and whether required scope inputs are available.

The result is a different failure model for DI.

A missing dependency, an invalid lifetime relationship, or an unavailable request-scoped value doesn't have to wait until application startup to fail. In many cases, TypeScript rejects the graph while you're building it.

And none of this requires decorators, reflection metadata, code generation, runtime dependencies, or a large runtime.

The core stays below 3 KiB gzipped.

npm install @inferdi/inferdi
Enter fullscreen mode Exit fullscreen mode

Or with JSR:

deno add jsr:@inferdi/inferdi
Enter fullscreen mode Exit fullscreen mode

The Graph Is the Type

Start with ordinary TypeScript classes:

import { Container } from '@inferdi/inferdi'

class Logger {
  log(message: string) {
    console.log(message)
  }
}

class UserRepository {
  constructor(
    private readonly logger: Logger,
    private readonly dsn: string
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

There is nothing DI-specific in them. No @Injectable(), no @Inject(), no reflect-metadata, and no special compiler configuration.

The graph is registered explicitly:

const container = new Container()
  .registerValue('dsn', 'postgres://localhost/app')
  .registerClass('logger', Logger, [])
  .registerClass(
    'userRepository',
    UserRepository,
    ['logger', 'dsn']
  )
Enter fullscreen mode Exit fullscreen mode

What's interesting here isn't simply that container.get() returns the right type.

The registration itself is checked.

Reference a dependency that doesn't exist:

.registerClass(
  'userRepository',
  UserRepository,
  ['logger', 'databaseUrl']
)
Enter fullscreen mode Exit fullscreen mode

TypeScript rejects it.

Put dependencies in an incompatible order and TypeScript rejects that too.

InferDI isn't maintaining an untyped bag of registrations and adding types only at the point of resolution. Each registration changes the container's type, so the graph accumulates as TypeScript type-state.

const repository = container.get('userRepository')
//    ^ UserRepository
Enter fullscreen mode Exit fullscreen mode

Unknown keys never make it as far as runtime in a correctly typed graph.

Lifetime Is Part of the Graph

Missing services are only one class of DI bug.

A nastier one is the captive dependency: a long-lived singleton accidentally retaining request-scoped state.

With InferDI, lifetime information is carried by the graph itself.

class RequestContext {
  constructor(readonly requestId: string) {}
}

class AppService {
  constructor(
    private readonly context: RequestContext
  ) {}
}

const container = new Container()
  .registerClass(
    'requestContext',
    RequestContext,
    [],
    'scoped'
  )
  .registerClass(
    'appService',
    AppService,
    ['requestContext'],
    'singleton'
  )
Enter fullscreen mode Exit fullscreen mode

The final registration is invalid.

A singleton cannot directly depend on a scoped service, so the compiler rejects the relationship.

In InferDI, lifetime isn't merely runtime configuration. It's part of the type-level graph.

The same model covers:

  • singleton
  • scoped
  • transient

The default runtime still performs cycle and lifetime checks as defense-in-depth for dynamic code, casts, and other ways around the type system. But for a normally typed graph, many of these mistakes are caught much earlier.

Async Is Graph State Too

Asynchronous initialization often turns DI APIs into a second, parallel abstraction.

InferDI keeps async services in the same graph.

Suppose a database requires asynchronous initialization:

const container = new Container()
  .registerValue('dsn', 'postgres://localhost/app')
  .registerAsyncFactory(
    'database',
    async (dsn) => Database.connect(dsn),
    ['dsn']
  )
Enter fullscreen mode Exit fullscreen mode

The graph stores the final service type:

const database = await container.getAsync('database')
//    ^ Database
Enter fullscreen mode Exit fullscreen mode

The registration represents an asynchronously initialized Database, not a Promise<Database> service.

The more useful part appears when something depends on it:

class UserRepository {
  constructor(
    readonly database: Database,
  ) {}
}

const container = new Container()
  .registerValue('dsn', 'postgres://localhost/app')
  .registerAsyncFactory(
    'database',
    async (dsn) => Database.connect(dsn),
    ['dsn']
  )
  .registerClass(
    'userRepository',
    UserRepository,
    ['database']
  )
Enter fullscreen mode Exit fullscreen mode

userRepository depends on an async service, so InferDI propagates that state through the graph.

const repository =
  await container.getAsync('userRepository')
Enter fullscreen mode Exit fullscreen mode

You don't have to rewrite every dependent registration as another async factory just because something deeper in the graph needs asynchronous initialization.

Singleton and scoped async services also use single-flight initialization. Concurrent resolutions join the same initialization instead of starting duplicate work.

Request Data Can Be Type-State

Some dependencies don't exist until a request, job, transaction, or similar execution boundary begins.

InferDI can model those values without introducing ambient global context.

type RequestContext = {
  requestId: string
  userId?: string
}

const root = new Container()
  .declareScopeInputs<{
    request: RequestContext
  }>()
Enter fullscreen mode Exit fullscreen mode

This declaration is type-only. It doesn't create a request object, store one globally, or read from AsyncLocalStorage.

The application provides the actual value when it creates a scope:

const scope = root.createScope({
  request: {
    requestId: crypto.randomUUID()
  }
})
Enter fullscreen mode Exit fullscreen mode

A service that depends on request isn't considered ready until that input exists.

So readiness itself becomes part of the graph's type-state.

This fits naturally with HTTP servers, serverless functions, background jobs, multi-tenant applications, transactions, and other systems where some dependencies only exist inside an execution boundary.

No DI-Specific Toolchain

InferDI doesn't need:

  • decorators
  • reflect-metadata
  • experimentalDecorators
  • emitDecoratorMetadata
  • TypeScript transformers
  • compiler plugins
  • code generation
  • runtime scanning
  • parameter-name parsing

The constructor signature defines the dependency types. The dependency tuple defines their order.

That gives TypeScript enough information to verify the relationship without making the domain class aware of the container:

class CheckoutService {
  constructor(
    private readonly payments: Payments,
    private readonly orders: Orders
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

CheckoutService doesn't know InferDI exists.

One Core Across Modern Runtimes

InferDI's core is framework-agnostic and has zero runtime dependencies.

It relies on ordinary modern JavaScript rather than a platform-specific DI mechanism, so the same core can be used across environments such as:

  • Node.js
  • Bun
  • Deno
  • browsers
  • Cloudflare Workers
  • Vercel Edge
  • serverless functions

Moving a service from a backend process to a worker or edge runtime doesn't require changing the DI architecture or introducing a different metadata pipeline.

The container remains the same.

Framework-specific integration stays outside the core. InferDI provides adapter packages for frameworks including Hono, Fastify, Elysia, Koa, and Express without making any of them a dependency of the container itself.

Under 3 KiB Gzipped

Strong static guarantees often arrive together with more runtime machinery.

InferDI deliberately avoids that trade-off.

The core has:

  • zero runtime dependencies
  • no reflection metadata
  • no proxy-based resolution
  • no decorator runtime
  • no graph-analysis engine in production
  • no framework machinery

The published core stays below 3 KiB gzipped.

That's useful anywhere, but particularly in serverless, edge, browser, CLI, and worker environments where startup cost and bundle size are part of the architecture rather than an afterthought.

Performance Is Part of the Design

The small runtime isn't the result of stripping features out of a conventional container. The resolution path itself is designed to stay small.

For an already-created service, the first operation in get() is the cache lookup.

No metadata traversal happens before it. There is no decorator lookup, proxy trap, or resolve middleware pipeline.

Class construction is optimized as well. Common constructor arities use direct calls rather than sending every instantiation through one generic reflection path.

InferDI exposes two runtime contracts.

The default container keeps runtime cycle and lifetime protection:

const container = new Container()
Enter fullscreen mode Exit fullscreen mode

For applications whose graphs are fixed and controlled at compile time, fast mode removes that bookkeeping:

const container = new Container({
  fast: true
})
Enter fullscreen mode Exit fullscreen mode

Fast mode isn't a way to make an invalid graph valid.

It's a fixed-graph contract: the application relies on the compile-time model instead of paying for the same runtime safety checks during resolution.

Performance here isn't an optimization applied after the API was designed. The resolve path is part of the architecture.

Benchmarks results

ℹ️ Source data
We generated the image and summary table above from benchmarks/results/public-2026-08-17T16-46-00-483Z.json. Open the raw result to inspect the eight rounds, each scenario's normalized ns/op measurements and Tinybench samples, plus the recorded environment and dependency versions. The benchmarks/README.md explains the workloads and aggregation method.

Lazy Dependencies Without Proxy Magic

Sometimes a dependency genuinely should be resolved later.

InferDI supports explicit lazy companions:

const container = new Container()
  .registerClass(
    'database',
    Database,
    [],
    'singleton',
    'databaseLazy'
  )
Enter fullscreen mode Exit fullscreen mode

The companion is a tiny wrapper:

const lazy = container.get('databaseLazy')

const database = lazy.get()
Enter fullscreen mode Exit fullscreen mode

Resolving the wrapper does not instantiate the database.

Async graphs use the corresponding AsyncLazy<T> behavior.

The important part is that laziness stays visible. Resolution isn't hidden behind a transparent proxy, and the target's lifetime semantics are preserved.

Native Resource Management

Scopes often own real resources:

  • database transactions
  • request-local clients
  • temporary files
  • streams
  • sockets
  • other disposable objects

InferDI integrates with JavaScript Explicit Resource Management:

async function handleRequest(request: Request) {
  await using scope = root.createScope({
    request
  })

  const handler = await scope.getAsync('handler')

  return handler.handle()
}
Enter fullscreen mode Exit fullscreen mode

When the scope ends, resources owned by that container are disposed in reverse creation order.

Async resources can use Symbol.asyncDispose; synchronous ones can use Symbol.dispose or a conventional dispose() method.

Ownership is explicit. InferDI doesn't simply dispose every object that happens to pass through the container.

Application-provided values, transient results, overrides, and scope inputs remain externally owned.

Modules Are Checked Graph Contracts

Real applications rarely build an entire dependency graph in one long function.

InferDI modules can describe both what a graph fragment requires and what it provides.

That keeps composition type-safe too.

A module can't quietly assume that the caller contains a dependency with the wrong lifetime or async state. It also can't silently overwrite an existing output key.

In other words, a module is a typed graph transformation rather than a bag of registrations executed at runtime.

This follows from the same underlying idea: the dependency graph is something TypeScript can reason about while the application is being composed.

What InferDI Is Not

InferDI deliberately leaves some things out.

It isn't trying to become:

  • a decorator framework
  • a reflection container
  • an auto-wiring scanner
  • an AOP/interceptor engine
  • a runtime module-discovery system
  • an ambient request-context system
  • a universal IoC framework

Those features can make sense in other architectures.

They don't belong in a DI core built around static graph guarantees, a tiny runtime, portability, and a minimal resolution path.

Why This Matters

TypeScript DI has traditionally involved trade-offs.

Runtime convenience often means more metadata or magic. Stronger typing tends to require a more explicit API. Performance-focused containers may give up architectural checks. Portable containers can end up with fewer features.

InferDI takes a different route.

Its graph carries architectural information directly through the type system:

  • service types
  • dependency compatibility
  • lifetimes
  • async propagation
  • lazy relationships
  • scope readiness
  • module requirements

At runtime, the core remains dependency-free and below 3 KiB gzipped.

There is no decorator metadata to reconstruct, no DI-specific build step, and no separate runtime graph model that has to rediscover information TypeScript already knew.

That's the real shift.

InferDI doesn't just add types to a dependency container. The dependency graph itself becomes part of the program's type-state.

And once the graph is part of the type system, TypeScript can prove considerably more about the application's architecture before that application ever starts.

Try InferDI

Documentation: https://inferdi.com
GitHub: https://github.com/inferdi/inferdi

If you want dependency injection without reflection, decorators, runtime dependencies, or unnecessary work on the resolution path, InferDI is built around exactly that model.

Top comments (0)