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:
packages/inversion/src/modules/container/container.ts-Containerpackages/inversion/src/modules/container/base.ts-BaseContainerpackages/inversion/src/modules/container/abstract.ts-AbstractContainerpackages/inversion/src/modules/binding/binding.ts-Bindingpackages/inversion/src/modules/binding/common/constants.ts-BindingScopes,BindingValueTypes,BindingKeyspackages/inversion/src/modules/binding/common/types.ts-IBinding,IProvider,isClassProviderpackages/inversion/src/modules/metadata/injectors.ts-@injectpackages/inversion/src/modules/metadata/common/constants.ts-MetadataKeyspackages/inversion/src/modules/registry/registry.ts-MetadataRegistry,metadataRegistrypackages/inversion/src/modules/registry/common/types.ts-IInjectMetadata,IPropertyMetadatapackages/inversion/src/common/types.ts-TNullable,ValueOrPromise,TClass,TConstValue,isClasspackages/inversion/src/modules/error/app-error.ts-ApplicationError,getError,isApplicationErrorpackages/inversion/src/modules/error/types.ts-TError,TErrorDefinition,TErrorNormalized,IErrorKeyRegistrypackages/inversion/src/common/logger.ts-Logger
Quick Reference
| Item | Value |
|---|---|
| Package | @venizia/ignis-inversion |
| Classes | Container, BaseContainer, AbstractContainer, Binding, MetadataRegistry |
| Decorators | @inject |
| Runtimes | Both Bun and Node.js |
| Build | Dual CJS + ESM |
Import paths
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 injectionAbstractContainer 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
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
| Method | Signature | Description |
|---|---|---|
bind | bind<T>(opts: { key: TBindingKey }): Binding<T> | Create a new Binding, register it under String(key), and return it |
get | get<T>(opts: { key: TBindingKey | { namespace, key }, isOptional?: boolean }): T | undefined | Resolve a dependency by key. Throws if not found and isOptional is falsy |
gets | gets<T>(opts: { bindings: Array<{ key, isOptional? }> }): T[] | Resolve multiple dependencies; every entry is internally re-issued with isOptional: true regardless of what was passed |
getBinding | getBinding<T>(opts: { key: TBindingKey | { namespace, key } }): Binding<T> | undefined | Retrieve the raw Binding without resolving it |
set | set<T>(opts: { binding: Binding<T> }): void | Register an externally created Binding under its own .key |
isBound | isBound(opts: { key: TBindingKey }): boolean | Check whether a key is registered |
unbind | unbind(opts: { key: TBindingKey }): boolean | Remove a binding; returns true if one was removed |
resolve | resolve<T>(cls: TClass<T>): T | Alias for instantiate |
instantiate | instantiate<T>(cls: TClass<T>): T | Build an instance with full DI (constructor + property injection), see below |
findByTag | findByTag<T>(opts: { tag: string, exclude?: string[] | Set<string> }): Binding<T>[] | Find every binding with a matching tag, optionally excluding keys |
clear | clear(): void | Clear every binding's singleton cache; bindings themselves remain registered |
reset | reset(): void | Remove all bindings entirely |
getMetadataRegistry | getMetadataRegistry(): MetadataRegistry | Return 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
override instantiate<T>(cls: TClass<T>): TPhase 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 atargs[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:
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
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
| Method | Signature | Description |
|---|---|---|
toClass | toClass(value: TClass<T>): this | Container instantiates value with full DI when resolved |
toValue | toValue(value: T): this | Returns value directly, no instantiation |
toProvider | toProvider(value: ((container) => T) | TClass<IProvider<T>>): this | Factory function, or a class whose prototype has a value() method |
setScope | setScope(scope: TBindingScope): this | 'singleton' or 'transient' (default) |
setTags | setTags(...tags: string[]): this | Adds tags to the internal Set<string> |
hasTag | hasTag(tag: string): boolean | Check for a specific tag |
getTags | getTags(): string[] | All tags as an array |
getScope | getScope(): TBindingScope | Current scope |
getValue | getValue(container?: IContainer): T | Resolve the bound value, respecting scope caching |
getBindingMeta | getBindingMeta(opts: { type: TBindingValueType }): TBindingResolverValue<T> | Raw resolver value; throws if type does not match the actual resolver |
clearCache | clearCache(): void | Clears 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
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 type | Behavior | Throws when |
|---|---|---|
VALUE | Returns the stored value directly | Never |
PROVIDER | If 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 instance | No container argument was passed - [getValue] Invalid context/container to get provider value |
CLASS | container.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
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
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.keyMetadataRegistry
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.
| Method | Signature | Description |
|---|---|---|
define | define<Target, Value>(opts: { target, key: TBindingKey, value: Value }): void | Store arbitrary metadata on a target |
get | get<Target, Value>(opts: { target, key: TBindingKey }): Value | undefined | Retrieve metadata by key |
has | has<Target>(opts: { target, key: TBindingKey }): boolean | Check if metadata exists |
delete | delete<Target>(opts: { target, key: TBindingKey }): boolean | Remove metadata by key |
getKeys | getKeys<Target>(opts: { target }): TBindingKey[] | List all metadata keys (string/symbol only) on a target |
getMethodNames | getMethodNames<T>(opts: { target: TClass<T> }): string[] | Non-constructor function-valued own property names on the prototype |
clearMetadata | clearMetadata<T>(opts: { target }): void | Delete every metadata key on a target |
setInjectMetadata | setInjectMetadata<T>(opts: { target, index: number, metadata: IInjectMetadata }): void | Write constructor @inject metadata at parameter index into the MetadataKeys.INJECT array |
getInjectMetadata | getInjectMetadata<T>(opts: { target }): IInjectMetadata[] | undefined | Read the constructor injection array |
setPropertyMetadata | setPropertyMetadata<T>(opts: { target, propertyName, metadata: IPropertyMetadata }): void | Write property @inject metadata into a Map keyed by property name, stored on target.constructor |
getPropertiesMetadata | getPropertiesMetadata<T>(opts: { target }): Map<string | symbol, IPropertyMetadata> | undefined | Read the full property metadata map |
getPropertyMetadata | getPropertyMetadata<T>(opts: { target, propertyName }): IPropertyMetadata | undefined | Read metadata for one property |
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' }); // trueMetadataRegistry types
interface IInjectMetadata {
key: TBindingKey;
index: number;
isOptional?: boolean;
}
interface IPropertyMetadata {
bindingKey: TBindingKey;
isOptional?: boolean;
[key: string]: any;
}Decorators
Source -> metadata/injectors.ts
@inject
inject(opts: { key: TBindingKey; isOptional?: boolean; registry?: MetadataRegistry }): PropertyDecorator | ParameterDecoratorDispatches on how the decorator was invoked:
| Applied to | Detection | Stored via |
|---|---|---|
| Constructor parameter | parameterIndex is a number | registry.setInjectMetadata({ target, index: parameterIndex, metadata: { key, index: parameterIndex, isOptional } }) |
| Class property | propertyName !== undefined | registry.setPropertyMetadata({ target, propertyName, metadata: { bindingKey: key, isOptional } }) |
| Anything else | neither condition matches | Throws @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).
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
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.
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
class ApplicationError extends Error {
statusCode: number;
normalized: { text: string; code: string; args: Record<string, unknown> };
extra?: Record<string, unknown>;
}
getError(opts: TError): ApplicationError; // factory functionopts.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.
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.
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 setType guards and shared types
Source -> common/types.ts
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.
interface IProvider<T> {
value(container: Container): T;
}
function isClassProvider<T>(target: any): target is TClass<IProvider<T>>;Constants
| Constant | Values | Description |
|---|---|---|
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.PROPERTIES | Symbol.for('ignis:properties') | Property injection metadata key |
MetadataKeys.INJECT | Symbol.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:
- Verify the binding exists:
container.isBound({ key: 'services.UserService' }). - Check for typos between
@inject({ key: '...' })and the key used incontainer.bind({ key: '...' }). - If the dependency is genuinely optional, use
@inject({ key: '...', isOptional: true })on a constructor parameter (not a property - see the warning above), orcontainer.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:
- 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. - Verify
tsconfig.jsonincludes: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:
- Always resolve via
container.get()rather than cachingBindingreferences. - Call
container.clear()to clear every binding's singleton cache without removing registrations. - Call
container.reset()to remove all bindings entirely.
See also
- Inversion overview - introduction and the most common tasks
- Dependency Injection Guide - DI fundamentals in the framework layer
- Application -
ApplicationextendsContainer - Dependency Injection API - the framework-layer DI reference
- Architectural Patterns - DI patterns