Skip to content

Inversion (DI) - Full Reference

Exhaustive reference for Container, Binding, MetadataRegistry, the @inject decorator, and every type, constant, and error message in @venizia/ignis-inversion. For a readable introduction and the most common tasks, start with the Inversion overview.

Files:

Quick Reference

ItemValue
Package@venizia/ignis-inversion
ClassesContainer, BaseContainer, AbstractContainer, Binding, MetadataRegistry
Decorators@inject
RuntimesBoth Bun and Node.js
BuildDual CJS + ESM

Import paths

typescript
import {
  Container,
  Binding,
  MetadataRegistry,
  metadataRegistry,
  inject,
  BindingKeys,
  BindingScopes,
  BindingValueTypes,
  MetadataKeys,
  BaseHelper,
  ApplicationError,
  getError,
  isApplicationError,
  MessageCode,
  ErrorScopes,
  Logger,
  isClass,
  isClassProvider,
} from '@venizia/ignis-inversion';

import type {
  TNullable,
  ValueOrPromise,
  ValueOf,
  TClass,
  TConstructor,
  TAbstractConstructor,
  TConstValue,
  TBindingKey,
  TBindingScope,
  TBindingValueType,
  IProvider,
  IBinding,
  IContainer,
  IInjectMetadata,
  IPropertyMetadata,
  IBindingTag,
} from '@venizia/ignis-inversion';

NOTE

The framework package @venizia/ignis re-exports DI-specific symbols (Binding, BindingKeys, BindingScopes, BindingValueTypes, IProvider, isClass, isClassProvider, TBindingScope, TBindingValueType, IBindingTag) from @venizia/ignis-inversion and adds higher-level helpers (app.controller(), app.service(), etc.). All types are also available via type-only re-exports.

Class Hierarchy

Source -> container/abstract.ts, container/base.ts, container/container.ts

AbstractContainer extends BaseHelper implements IContainer   # contract only - every member abstract
  └── BaseContainer                                          # storage: bind/lookup/tags/lifecycle, `instantiate` still abstract
        └── Container                                        # instantiate() = two-phase decorator injection

AbstractContainer exists so a container implementation that shares nothing with the shipped storage can start there; one that only wants to vary resolution extends BaseContainer instead. binding/ and container/ only talk to each other through IContainer/IBinding - there is no import cycle between the two folders.

Creating a Container

typescript
import { Container } from '@venizia/ignis-inversion';

const container = new Container({ scope: 'MyApp' });

scope is optional and defaults to 'Container' (or 'BaseContainer'/'AbstractContainer' for those classes). It is passed through to BaseHelper and used for logging/error context only - it has no effect on binding resolution.

Container

Source -> container/base.ts, container/container.ts

MethodSignatureDescription
bindbind<T>(opts: { key: TBindingKey }): Binding<T>Create a new Binding, register it under String(key), and return it
getget<T>(opts: { key: TBindingKey | { namespace, key }, isOptional?: boolean }): T | undefinedResolve a dependency by key. Throws if not found and isOptional is falsy
getsgets<T>(opts: { bindings: Array<{ key, isOptional? }> }): T[]Resolve multiple dependencies; every entry is internally re-issued with isOptional: true regardless of what was passed
getBindinggetBinding<T>(opts: { key: TBindingKey | { namespace, key } }): Binding<T> | undefinedRetrieve the raw Binding without resolving it
setset<T>(opts: { binding: Binding<T> }): voidRegister an externally created Binding under its own .key
isBoundisBound(opts: { key: TBindingKey }): booleanCheck whether a key is registered
unbindunbind(opts: { key: TBindingKey }): booleanRemove a binding; returns true if one was removed
resolveresolve<T>(cls: TClass<T>): TAlias for instantiate
instantiateinstantiate<T>(cls: TClass<T>): TBuild an instance with full DI (constructor + property injection), see below
findByTagfindByTag<T>(opts: { tag: string, exclude?: string[] | Set<string> }): Binding<T>[]Find every binding with a matching tag, optionally excluding keys
clearclear(): voidClear every binding's singleton cache; bindings themselves remain registered
resetreset(): voidRemove all bindings entirely
getMetadataRegistrygetMetadataRegistry(): MetadataRegistryReturn the shared metadataRegistry singleton

All keys passed to bind, isBound, and unbind are normalized with String(key) before being used as the Map key - a Symbol key resolves to its .toString() form ('Symbol(...)'), so a symbol and the equivalent string are distinct entries.

instantiate() - two-phase algorithm

typescript
override instantiate<T>(cls: TClass<T>): T

Phase 1 - constructor injection. Reads registry.getInjectMetadata({ target: cls }), which returns the index-keyed IInjectMetadata[] array built by @inject. For every index in that array:

  • If the slot is empty (an undecorated parameter left a hole), throws [ClassName] Constructor parameter N has no @inject | Every parameter of a container-instantiated class must be decorated - the container cannot supply an undecorated one.
  • Otherwise resolves this.get({ key: meta.key, isOptional: meta.isOptional ?? false }) and places it at args[meta.index].

The array is already index-keyed (setInjectMetadata writes to injects[index]) - there is no sort step. Once all arguments are resolved, new cls(...args) builds the instance.

Phase 2 - property injection. Reads registry.getPropertiesMetadata({ target: instance }). If there is none, returns the instance as-is. Otherwise, for each [propertyKey, metadata] entry, resolves this.get({ key: metadata.bindingKey, isOptional: metadata.optional ?? false }) and assigns it to instance[propertyKey].

WARNING

Property injection's optional check reads metadata.optional, but @inject writes IPropertyMetadata.isOptional (see IPropertyMetadata below) - metadata.optional is always undefined at runtime, so metadata.optional ?? false always evaluates to false. In practice, @inject({ key, isOptional: true }) on a property has no effect: an unbound property dependency still throws. This is a verified source-level discrepancy in container.ts, not a documented API - isOptional on a constructor parameter (meta.isOptional, phase 1) works correctly. If you need an optional dependency, use @inject on a constructor parameter, or resolve it manually with container.get({ key, isOptional: true }) inside the constructor.

Key formats

get, getBinding, and gets accept three key shapes:

typescript
container.get<UserService>({ key: 'services.UserService' });
container.get<UserService>({ key: Symbol.for('services.UserService') });
container.get<UserService>({ key: { namespace: 'services', key: 'UserService' } });

getBinding throws [getBinding] Invalid binding key type | ... if opts.key is not a string, symbol, or { namespace, key } object.

gets() behavior

typescript
const [svcA, svcB] = container.gets<[ServiceA, ServiceB]>({
  bindings: [
    { key: 'services.ServiceA' },
    { key: 'services.ServiceB', isOptional: true },
  ],
});

Internally maps each entry through this.get({ ...opt, isOptional: true }) - regardless of what isOptional was set to on the entry, gets() always resolves with isOptional: true and returns undefined for anything unbound rather than throwing.

Binding

Source -> binding/binding.ts

MethodSignatureDescription
toClasstoClass(value: TClass<T>): thisContainer instantiates value with full DI when resolved
toValuetoValue(value: T): thisReturns value directly, no instantiation
toProvidertoProvider(value: ((container) => T) | TClass<IProvider<T>>): thisFactory function, or a class whose prototype has a value() method
setScopesetScope(scope: TBindingScope): this'singleton' or 'transient' (default)
setTagssetTags(...tags: string[]): thisAdds tags to the internal Set<string>
hasTaghasTag(tag: string): booleanCheck for a specific tag
getTagsgetTags(): string[]All tags as an array
getScopegetScope(): TBindingScopeCurrent scope
getValuegetValue(container?: IContainer): TResolve the bound value, respecting scope caching
getBindingMetagetBindingMeta(opts: { type: TBindingValueType }): TBindingResolverValue<T>Raw resolver value; throws if type does not match the actual resolver
clearCacheclearCache(): voidClears this binding's singleton cache (no-op if nothing cached)
bind (static)static bind<T>(opts: { key: string }): Binding<T>Create a Binding outside a container

Constructor and namespace auto-tagging

typescript
constructor(opts: { key: string })

Splits key on .; if there is more than one segment, the first segment is auto-added as a tag via setTags(). 'services.UserService' auto-tags 'services'; a key with no . gets no automatic tag.

getValue() resolution by type

Resolver typeBehaviorThrows when
VALUEReturns the stored value directlyNever
PROVIDERIf the stored value is a plain function, calls provider(container). If it is a class matching isClassProvider (prototype has a value() method), the container instantiate()s the class first, then calls .value(container) on the instanceNo container argument was passed - [getValue] Invalid context/container to get provider value
CLASScontainer.instantiate(this.resolver.value)No container argument was passed - [getValue] Invalid context/container to instantiate class

If bindScope is SINGLETON, the resolved instance is cached on this.cached and returned directly on every subsequent call without re-invoking the resolver - caching is per-Binding instance, not per-container.

Class-based provider

typescript
import { IProvider, Container } from '@venizia/ignis-inversion';

class DatabaseConnectionProvider implements IProvider<DatabaseConnection> {
  value(container: Container): DatabaseConnection {
    const config = container.get<Config>({ key: 'config.database' });
    return new DatabaseConnection(config);
  }
}

container.bind<DatabaseConnection>({ key: 'db.connection' }).toProvider(DatabaseConnectionProvider);

isClassProvider detects this shape at runtime: typeof target === 'function' && target.prototype && typeof target.prototype.value === 'function'.

Static factory

typescript
import { Binding, BindingScopes } from '@venizia/ignis-inversion';

const binding = Binding.bind<IHealthCheckOptions>({ key: 'options.healthCheck' })
  .toValue({ restOptions: { path: '/health' } });

container.set({ binding }); // registers under binding.key

MetadataRegistry

Source -> registry/registry.ts

Singleton (metadataRegistry) backing @inject, built entirely on reflect-metadata's Reflect.defineMetadata/getMetadata/hasMetadata/deleteMetadata. You typically interact with it only through container.getMetadataRegistry() or the decorators.

MethodSignatureDescription
definedefine<Target, Value>(opts: { target, key: TBindingKey, value: Value }): voidStore arbitrary metadata on a target
getget<Target, Value>(opts: { target, key: TBindingKey }): Value | undefinedRetrieve metadata by key
hashas<Target>(opts: { target, key: TBindingKey }): booleanCheck if metadata exists
deletedelete<Target>(opts: { target, key: TBindingKey }): booleanRemove metadata by key
getKeysgetKeys<Target>(opts: { target }): TBindingKey[]List all metadata keys (string/symbol only) on a target
getMethodNamesgetMethodNames<T>(opts: { target: TClass<T> }): string[]Non-constructor function-valued own property names on the prototype
clearMetadataclearMetadata<T>(opts: { target }): voidDelete every metadata key on a target
setInjectMetadatasetInjectMetadata<T>(opts: { target, index: number, metadata: IInjectMetadata }): voidWrite constructor @inject metadata at parameter index into the MetadataKeys.INJECT array
getInjectMetadatagetInjectMetadata<T>(opts: { target }): IInjectMetadata[] | undefinedRead the constructor injection array
setPropertyMetadatasetPropertyMetadata<T>(opts: { target, propertyName, metadata: IPropertyMetadata }): voidWrite property @inject metadata into a Map keyed by property name, stored on target.constructor
getPropertiesMetadatagetPropertiesMetadata<T>(opts: { target }): Map<string | symbol, IPropertyMetadata> | undefinedRead the full property metadata map
getPropertyMetadatagetPropertyMetadata<T>(opts: { target, propertyName }): IPropertyMetadata | undefinedRead metadata for one property
typescript
import { MetadataKeys, metadataRegistry } from '@venizia/ignis-inversion';

MetadataKeys.PROPERTIES; // Symbol.for('ignis:properties')
MetadataKeys.INJECT;     // Symbol.for('ignis:inject')

metadataRegistry.define({ target: myObj, key: 'custom:flag', value: true });
metadataRegistry.get({ target: myObj, key: 'custom:flag' });    // true
metadataRegistry.has({ target: myObj, key: 'custom:flag' });    // true
metadataRegistry.delete({ target: myObj, key: 'custom:flag' }); // true

MetadataRegistry types

typescript
interface IInjectMetadata {
  key: TBindingKey;
  index: number;
  isOptional?: boolean;
}

interface IPropertyMetadata {
  bindingKey: TBindingKey;
  isOptional?: boolean;
  [key: string]: any;
}

Decorators

Source -> metadata/injectors.ts

@inject

typescript
inject(opts: { key: TBindingKey; isOptional?: boolean; registry?: MetadataRegistry }): PropertyDecorator | ParameterDecorator

Dispatches on how the decorator was invoked:

Applied toDetectionStored via
Constructor parameterparameterIndex is a numberregistry.setInjectMetadata({ target, index: parameterIndex, metadata: { key, index: parameterIndex, isOptional } })
Class propertypropertyName !== undefinedregistry.setPropertyMetadata({ target, propertyName, metadata: { bindingKey: key, isOptional } })
Anything elseneither condition matchesThrows @inject decorator can only be used on class properties or constructor parameters

isOptional defaults to false in both branches. Pass a custom registry to target a non-default MetadataRegistry instance (rare - almost always omitted, using the shared metadataRegistry).

typescript
class UserService {
  constructor(
    @inject({ key: 'repositories.UserRepository' }) private userRepository: UserRepository,
    @inject({ key: 'services.Logger', isOptional: true }) private logger?: Logger,
  ) {}

  @inject({ key: 'config.retryCount' })
  private retryCount: number;
}

Namespaces and Tags

typescript
import { BindingKeys } from '@venizia/ignis-inversion';

BindingKeys.build({ namespace: 'services', key: 'UserService' });
// => 'services.UserService'

BindingKeys.build({ namespace: '', key: 'UserService' });
// => 'UserService' (empty namespace segment is dropped)

BindingKeys.build({ namespace: 'services', key: '' });
// throws: [BindingKeys][build] Invalid key to build | key:

key is required and must be non-empty; namespace is optional and silently omitted from the joined string when empty.

typescript
container.bind({ key: 'workers.EmailWorker' }).toClass(EmailWorker).setTags('background', 'email');
// tags: ['workers', 'background', 'email']

const serviceBindings = container.findByTag({ tag: 'services' });
const filtered = container.findByTag({ tag: 'services', exclude: ['services.InternalService'] });

Utilities

ApplicationError and getError

Source -> modules/error/app-error.ts

typescript
class ApplicationError extends Error {
  statusCode: number;
  normalized: { text: string; code: string; args: Record<string, unknown> };
  extra?: Record<string, unknown>;
}

getError(opts: TError): ApplicationError; // factory function

opts.message accepts two shapes: the historical string (paired with sibling messageCode?/messageArgs?), or an object mirroring normalized - { text, code?, args? }. Both resolve to the same normalized; messageCode/messageArgs are lowest precedence, so message.code/message.args (or a catalogued definition's own message.code/message.args) win when both are present. There is no flat error.messageCode, and extra never mirrors messageArgs. normalized.args is always populated ({} when empty).

The catalogued form ({ error: TErrorDefinition }) takes message as a partial override - { message: { args } } amends just the args and keeps the definition's text/code. error is refused on the free-form branch (error?: never) - wrap a caught failure with cause instead.

ApplicationError's constructor defaults statusCode to 400 when omitted, and moves any property it does not model into this.extra. The error RESPONSE schema (ErrorSchema, for OpenAPI) lives in @venizia/ignis-helpers, not here - it needs @hono/zod-openapi, which inversion must not depend on because it ships to browsers.

typescript
throw getError({ message: 'Something failed', statusCode: 500, messageCode: 'ERR_INTERNAL' });
throw new ApplicationError({ message: 'Not found', statusCode: 404 });
throw new ApplicationError({ message: { text: 'Not found', code: 'core.user.not_found' }, statusCode: 404 });

// The code and args are read off `normalized`, never off the error directly.
error.normalized.code; // 'err_internal'

See the Error reference for the full input shape, precedence rules, and the catalogued (TErrorDefinition) pattern.

Logger

Source -> common/logger.ts

A minimal console-backed static logger, independent of @venizia/ignis-helpers' Logger/LoggerFactory - this one exists so the inversion package has zero dependency on the logging package.

typescript
Logger.info('Server started on port %d', 3000);   // console.log('[INFO] ...')
Logger.warn('Deprecation warning');                 // console.warn('[WARN] ...')
Logger.error('Connection failed: %s', err.message); // console.error('[ERROR] ...')
Logger.debug('Resolved binding: %s', key);          // console.log('[DEBUG] ...') only if process.env.DEBUG is set

Type guards and shared types

Source -> common/types.ts

typescript
type TNullable<T> = T | undefined | null;
type ValueOrPromise<T> = T | Promise<T>;
type ValueOf<T> = T[keyof T];
type TConstructor<T> = new (...args: any[]) => T;
type TAbstractConstructor<T> = abstract new (...args: any[]) => T;
type TClass<T> = TConstructor<T> & { [property: string]: any };
type TConstValue<T extends TClass<any>> = Extract<ValueOf<T>, string | number>;
type TBindingKey = string | symbol;

interface IBindingTag {
  [name: string]: any;
}

function isClass<T>(target: any): target is TClass<T>;

isClass tests typeof target === 'function' && target.prototype !== undefined plus a regex match on the function's stringified source (/^class[\s{]/) - it relies on the class being emitted as an ES2024 class, not transpiled down to an ES5 constructor function.

typescript
interface IProvider<T> {
  value(container: Container): T;
}

function isClassProvider<T>(target: any): target is TClass<IProvider<T>>;

Constants

ConstantValuesDescription
BindingScopes.SINGLETON'singleton'Cached after first resolution
BindingScopes.TRANSIENT'transient'New instance each resolution
BindingValueTypes.CLASS'class'Container instantiates with DI
BindingValueTypes.VALUE'value'Direct value return
BindingValueTypes.PROVIDER'provider'Factory function or IProvider class
MetadataKeys.PROPERTIESSymbol.for('ignis:properties')Property injection metadata key
MetadataKeys.INJECTSymbol.for('ignis:inject')Constructor injection metadata key

Troubleshooting

"Binding key: X is not bounded in context!"

Cause: The dependency was never registered with the container, or the key does not match exactly.

Fix:

  1. Verify the binding exists: container.isBound({ key: 'services.UserService' }).
  2. Check for typos between @inject({ key: '...' }) and the key used in container.bind({ key: '...' }).
  3. If the dependency is genuinely optional, use @inject({ key: '...', isOptional: true }) on a constructor parameter (not a property - see the warning above), or container.get({ key: '...', isOptional: true }).

"[getValue] Invalid context/container to instantiate class"

Cause: A Binding configured with toClass() was resolved by calling binding.getValue() directly, without a Container argument.

Fix: Resolve class bindings through the container - container.get({ key }) - rather than calling binding.getValue() with no arguments.

"[getValue] Invalid context/container to get provider value"

Cause: A Binding configured with toProvider() was resolved without a Container argument.

Fix: Same as above - always resolve through container.get({ key }).

"[getBindingMeta] Invalid resolver type"

Cause: getBindingMeta({ type }) was called with a type that does not match the binding's actual resolver (e.g. 'class' on a value binding).

Fix: Match type to how the binding was created: toClass() -> 'class', toValue() -> 'value', toProvider() -> 'provider'.

"[getBinding] Invalid binding key type"

Cause: The key passed to getBinding() is not a string, symbol, or { namespace, key } object.

Fix: Use one of the three supported key formats.

"[BindingKeys][build] Invalid key to build"

Cause: BindingKeys.build() was called with an empty key.

Fix: Provide a non-empty key: BindingKeys.build({ namespace: 'services', key: 'UserService' }).

"[ClassName] Constructor parameter N has no @inject"

Cause: A container-instantiated class has a constructor mixing decorated and undecorated parameters. @inject stores metadata at the parameter's index, so an undecorated parameter leaves a hole in that array; there is no channel through which the container could supply it anyway.

Fix: Decorate every constructor parameter with @inject. There is no partial-injection escape hatch - if a value does not come from the container (e.g. a plain scope: string), pass it through a factory/provider instead of a bare constructor parameter, or have the subclass forward it via its own @inject-decorated parameter.

"@inject decorator can only be used on class properties or constructor parameters"

Cause: @inject was applied to something other than a class property or constructor parameter.

Fix: Only use @inject on constructor parameters or class properties.

Property injection returns undefined even without isOptional

Cause: Either (a) the class was instantiated with new MyClass() directly instead of through the container - only the container reads @inject metadata and populates properties - or (b) the binding key really is unbound, and the isOptional: true you set on the property has no effect (see the phase-2 warning above, metadata.optional vs metadata.isOptional).

Fix: Always use container.resolve(MyClass) or container.instantiate(MyClass) to create instances. If the dependency may legitimately be absent, inject it on the constructor instead of a property, or resolve it manually inside the constructor with container.get({ key, isOptional: true }).

"getInjectMetadata returns undefined"

Cause: reflect-metadata was not imported before decorators were evaluated, or experimentalDecorators/emitDecoratorMetadata are not enabled in tsconfig.json.

Fix:

  1. Ensure import 'reflect-metadata' runs before any decorated class is evaluated - @venizia/ignis-inversion's entry point already does this, so importing anything from the package is enough.
  2. Verify tsconfig.json includes:
    json
    {
      "compilerOptions": {
        "experimentalDecorators": true,
        "emitDecoratorMetadata": true
      }
    }

Singleton returns a stale instance after rebinding

Cause: Singleton caching is per-Binding object. Holding a direct reference to an old Binding (e.g. from getBinding()) keeps its cache independent of any new binding registered under the same key.

Fix:

  1. Always resolve via container.get() rather than caching Binding references.
  2. Call container.clear() to clear every binding's singleton cache without removing registrations.
  3. Call container.reset() to remove all bindings entirely.

See also