Hooks

Hooks provide a way to execute code at specific lifecycle points of an instance. They enable initialization logic after construction, cleanup logic before disposal, property injection, and custom lifecycle management. The hook system is flexible and extensible, allowing you to create custom hooks for your specific needs.

What are Hooks?

Hooks are methods decorated with special decorators that execute at specific points in an instance’s lifecycle. They can:

The hook system uses HooksRunner to execute hooks, which can be synchronous or asynchronous. Hooks can resolve dependencies from the container, making them powerful tools for managing complex initialization scenarios.

OnConstruct Hooks

OnConstruct hooks execute after an instance is created. This is useful for initialization logic that needs access to the container or other dependencies that can’t be injected via the constructor.

Basic Usage

Register an onConstruct hook on your container, then decorate methods with @onConstruct:

TypeScript __tests__/hooks/OnConstruct.spec.ts
import {
  AddOnConstructHookModule,
  Container,
  HookContext,
  HookFn,
  inject,
  onConstruct,
  Registration as R,
} from 'ts-ioc-container';

/**
 * Lifecycle - OnConstruct Hook
 *
 * The @onConstruct hook allows you to run logic immediately after an object is created.
 * This is useful for:
 * - Initialization logic that depends on injected services
 * - Setting up event listeners
 * - Establishing connections (though lazy is often better)
 * - Computing initial state
 *
 * Note: You must register the AddOnConstructHookModule or manually add the hook runner.
 */

const execute: HookFn = (ctx: HookContext) => {
  ctx.invokeMethod({ args: ctx.resolveArgs() });
};

describe('onConstruct', function () {
  it('should run initialization method after dependencies are resolved', function () {
    class DatabaseConnection {
      isConnected = false;
      connectionString = '';

      // @onConstruct marks this method to be called after instantiation
      // Arguments are resolved from the container like constructor params
      @onConstruct(execute)
      connect(@inject('ConnectionString') connectionString: string) {
        this.connectionString = connectionString;
        this.isConnected = true;
      }
    }

    const container = new Container()
      // Enable @onConstruct support
      .useModule(new AddOnConstructHookModule())
      // Register config
      .addRegistration(R.fromValue('postgres://localhost:5432').bindTo('ConnectionString'));

    // Resolve class - constructor is called, then @onConstruct method
    const db = container.resolve(DatabaseConnection);

    expect(db.isConnected).toBe(true);
    expect(db.connectionString).toBe('postgres://localhost:5432');
  });
});
import {
  AddOnConstructHookModule,
  Container,
  HookContext,
  HookFn,
  inject,
  onConstruct,
  Registration as R,
} from 'ts-ioc-container';

/**
 * Lifecycle - OnConstruct Hook
 *
 * The @onConstruct hook allows you to run logic immediately after an object is created.
 * This is useful for:
 * - Initialization logic that depends on injected services
 * - Setting up event listeners
 * - Establishing connections (though lazy is often better)
 * - Computing initial state
 *
 * Note: You must register the AddOnConstructHookModule or manually add the hook runner.
 */

const execute: HookFn = (ctx: HookContext) => {
  ctx.invokeMethod({ args: ctx.resolveArgs() });
};

describe('onConstruct', function () {
  it('should run initialization method after dependencies are resolved', function () {
    class DatabaseConnection {
      isConnected = false;
      connectionString = '';

      // @onConstruct marks this method to be called after instantiation
      // Arguments are resolved from the container like constructor params
      @onConstruct(execute)
      connect(@inject('ConnectionString') connectionString: string) {
        this.connectionString = connectionString;
        this.isConnected = true;
      }
    }

    const container = new Container()
      // Enable @onConstruct support
      .useModule(new AddOnConstructHookModule())
      // Register config
      .addRegistration(R.fromValue('postgres://localhost:5432').bindTo('ConnectionString'));

    // Resolve class - constructor is called, then @onConstruct method
    const db = container.resolve(DatabaseConnection);

    expect(db.isConnected).toBe(true);
    expect(db.connectionString).toBe('postgres://localhost:5432');
  });
});

Use Cases

OnDispose Hooks

OnDispose hooks execute before an instance is disposed, allowing you to clean up resources, save state, or perform other cleanup operations.

Basic Usage

Register an onDispose hook on your container, then decorate methods with @onDispose:

TypeScript __tests__/hooks/OnDispose.spec.ts
import {
  AddOnDisposeHookModule,
  bindTo,
  Container,
  type HookFn,
  inject,
  onDispose,
  register,
  Registration as R,
  singleton,
} from 'ts-ioc-container';

const execute: HookFn = (ctx) => {
  ctx.invokeMethod({ args: ctx.resolveArgs() });
};

@register(bindTo('logsRepo'), singleton())
class LogsRepo {
  savedLogs: string[] = [];

  saveLogs(messages: string[]) {
    this.savedLogs.push(...messages);
  }
}

@register(bindTo('logger'))
class Logger {
  @onDispose(({ instance, methodName }) => {
    // @ts-ignore
    instance[methodName].push('world');
  }) // <--- or extract it to @onDispose
  private messages: string[] = [];

  constructor(@inject('logsRepo') private logsRepo: LogsRepo) {}

  log(message: string): void {
    this.messages.push(message);
  }

  @onDispose(execute)
  save() {
    this.logsRepo.saveLogs(this.messages);
  }
}

describe('onDispose', function () {
  it('should invoke hooks on all instances', function () {
    const container = new Container()
      .useModule(new AddOnDisposeHookModule())
      .addRegistration(R.fromClass(Logger))
      .addRegistration(R.fromClass(LogsRepo));

    const logger = container.resolve<Logger>('logger');
    logger.log('Hello');
    const logsRepo = container.resolve<LogsRepo>('logsRepo');

    container.dispose();

    expect(logsRepo.savedLogs.join(',')).toBe('Hello,world');
  });
});
import {
  AddOnDisposeHookModule,
  bindTo,
  Container,
  type HookFn,
  inject,
  onDispose,
  register,
  Registration as R,
  singleton,
} from 'ts-ioc-container';

const execute: HookFn = (ctx) => {
  ctx.invokeMethod({ args: ctx.resolveArgs() });
};

@register(bindTo('logsRepo'), singleton())
class LogsRepo {
  savedLogs: string[] = [];

  saveLogs(messages: string[]) {
    this.savedLogs.push(...messages);
  }
}

@register(bindTo('logger'))
class Logger {
  @onDispose(({ instance, methodName }) => {
    // @ts-ignore
    instance[methodName].push('world');
  }) // <--- or extract it to @onDispose
  private messages: string[] = [];

  constructor(@inject('logsRepo') private logsRepo: LogsRepo) {}

  log(message: string): void {
    this.messages.push(message);
  }

  @onDispose(execute)
  save() {
    this.logsRepo.saveLogs(this.messages);
  }
}

describe('onDispose', function () {
  it('should invoke hooks on all instances', function () {
    const container = new Container()
      .useModule(new AddOnDisposeHookModule())
      .addRegistration(R.fromClass(Logger))
      .addRegistration(R.fromClass(LogsRepo));

    const logger = container.resolve<Logger>('logger');
    logger.log('Hello');
    const logsRepo = container.resolve<LogsRepo>('logsRepo');

    container.dispose();

    expect(logsRepo.savedLogs.join(',')).toBe('Hello,world');
  });
});

Use Cases

Property Injection

Property injection allows you to inject dependencies into properties using hooks. This is useful when constructor injection isn’t possible or when you need to inject into base class properties.

Using injectProp with Hooks

The injectProp helper can be used with the @hook decorator to inject dependencies into properties:

TypeScript __tests__/readme/injectProp.spec.ts
import 'reflect-metadata';
import { Container, hook, HooksRunner, injectProp, Registration } from 'ts-ioc-container';

/**
 * UI Components - Property Injection
 *
 * Property injection is useful when you don't control the class instantiation
 * (like in some UI frameworks, Web Components, or legacy systems) or when
 * you want to avoid massive constructors in base classes.
 *
 * This example demonstrates a ViewModel that gets dependencies injected
 * AFTER construction via an initialization hook.
 */

describe('inject property', () => {
  it('should inject property', () => {
    // Runner for the 'onInit' lifecycle hook
    const onInitHookRunner = new HooksRunner('onInit');

    class UserViewModel {
      // Inject 'GreetingService' into 'greeting' property during 'onInit'
      @hook('onInit', injectProp('GreetingService'))
      greetingService!: string;

      display(): string {
        return `${this.greetingService} User`;
      }
    }

    const container = new Container().addRegistration(Registration.fromValue('Hello').bindToKey('GreetingService'));

    // 1. Create instance (dependencies not yet injected)
    const viewModel = container.resolve(UserViewModel);

    // 2. Run lifecycle hooks to inject properties
    onInitHookRunner.execute(viewModel, { scope: container });

    expect(viewModel.greetingService).toBe('Hello');
    expect(viewModel.display()).toBe('Hello User');
  });
});
import 'reflect-metadata';
import { Container, hook, HooksRunner, injectProp, Registration } from 'ts-ioc-container';

/**
 * UI Components - Property Injection
 *
 * Property injection is useful when you don't control the class instantiation
 * (like in some UI frameworks, Web Components, or legacy systems) or when
 * you want to avoid massive constructors in base classes.
 *
 * This example demonstrates a ViewModel that gets dependencies injected
 * AFTER construction via an initialization hook.
 */

describe('inject property', () => {
  it('should inject property', () => {
    // Runner for the 'onInit' lifecycle hook
    const onInitHookRunner = new HooksRunner('onInit');

    class UserViewModel {
      // Inject 'GreetingService' into 'greeting' property during 'onInit'
      @hook('onInit', injectProp('GreetingService'))
      greetingService!: string;

      display(): string {
        return `${this.greetingService} User`;
      }
    }

    const container = new Container().addRegistration(Registration.fromValue('Hello').bindToKey('GreetingService'));

    // 1. Create instance (dependencies not yet injected)
    const viewModel = container.resolve(UserViewModel);

    // 2. Run lifecycle hooks to inject properties
    onInitHookRunner.execute(viewModel, { scope: container });

    expect(viewModel.greetingService).toBe('Hello');
    expect(viewModel.display()).toBe('Hello User');
  });
});

When to Use Property Injection

Custom Hooks

You can create custom hooks for any lifecycle event or application-specific scenario. Custom hooks use the same HooksRunner system but with your own hook names.

Creating Custom Hooks

Create a custom hook by instantiating a HooksRunner with a unique hook name:

TypeScript __tests__/readme/customHooks.spec.ts
import { Container, hook, HooksRunner, type HookFn } from 'ts-ioc-container';

/**
 * User Management Domain - Custom Lifecycle Hooks
 *
 * Custom hooks extend the container's lifecycle management beyond
 * the built-in @onConstruct and @onDispose hooks.
 *
 * Use cases:
 * - @validateConfig: Validate service configuration after construction
 * - @warmCache: Pre-populate caches when service is created
 * - @registerMetrics: Register service with monitoring system
 * - @auditCreation: Log service creation for compliance
 *
 * How it works:
 * 1. Define a HooksRunner with a unique hook name
 * 2. Create methods decorated with @hook('hookName', executor)
 * 3. Register the hook runner via addOnConstructHook
 * 4. Methods are automatically called when instances are created
 */

// Create a custom hook runner for initialization
const initializeHookRunner = new HooksRunner('initialize');

// Hook executor - defines what happens when the hook fires
const executeInitialize: HookFn = (ctx) => {
  ctx.invokeMethod({ args: ctx.resolveArgs() });
};

describe('Custom Hooks', () => {
  it('should execute custom initialization hook after construction', () => {
    class CacheService {
      isWarmedUp = false;

      // Custom hook - called automatically after construction
      @hook('initialize', executeInitialize)
      warmCache() {
        this.isWarmedUp = true;
      }
    }

    const container = new Container({ tags: ['application'] }).addOnConstructHook((instance, scope) => {
      // Run all 'initialize' hooks on newly created instances
      initializeHookRunner.execute(instance, { scope });
    });

    const cacheService = container.resolve(CacheService);

    // Hook was automatically executed
    expect(cacheService.isWarmedUp).toBe(true);
  });
});
import { Container, hook, HooksRunner, type HookFn } from 'ts-ioc-container';

/**
 * User Management Domain - Custom Lifecycle Hooks
 *
 * Custom hooks extend the container's lifecycle management beyond
 * the built-in @onConstruct and @onDispose hooks.
 *
 * Use cases:
 * - @validateConfig: Validate service configuration after construction
 * - @warmCache: Pre-populate caches when service is created
 * - @registerMetrics: Register service with monitoring system
 * - @auditCreation: Log service creation for compliance
 *
 * How it works:
 * 1. Define a HooksRunner with a unique hook name
 * 2. Create methods decorated with @hook('hookName', executor)
 * 3. Register the hook runner via addOnConstructHook
 * 4. Methods are automatically called when instances are created
 */

// Create a custom hook runner for initialization
const initializeHookRunner = new HooksRunner('initialize');

// Hook executor - defines what happens when the hook fires
const executeInitialize: HookFn = (ctx) => {
  ctx.invokeMethod({ args: ctx.resolveArgs() });
};

describe('Custom Hooks', () => {
  it('should execute custom initialization hook after construction', () => {
    class CacheService {
      isWarmedUp = false;

      // Custom hook - called automatically after construction
      @hook('initialize', executeInitialize)
      warmCache() {
        this.isWarmedUp = true;
      }
    }

    const container = new Container({ tags: ['application'] }).addOnConstructHook((instance, scope) => {
      // Run all 'initialize' hooks on newly created instances
      initializeHookRunner.execute(instance, { scope });
    });

    const cacheService = container.resolve(CacheService);

    // Hook was automatically executed
    expect(cacheService.isWarmedUp).toBe(true);
  });
});

Synchronous vs Asynchronous Hooks

Hooks can be synchronous or asynchronous. Use the appropriate execution method:

TypeScript __tests__/hooks/hook.spec.ts
import {
  AddOnConstructHookModule,
  AddOnDisposeHookModule,
  Container,
  hasHooks,
  hook,
  type HookFn,
  HooksRunner,
  inject,
  onConstruct,
  onDispose,
  Registration as R,
  UnexpectedHookResultError,
} from 'ts-ioc-container';

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

const execute: HookFn = (ctx) => {
  ctx.invokeMethod({ args: ctx.resolveArgs() });
};

const executeAsync: HookFn = async (ctx) => {
  await ctx.invokeMethod({ args: ctx.resolveArgs() });
};

const beforeHooksRunner = new HooksRunner('syncBefore');
describe('hooks', () => {
  it('should run runHooks only for sync hooks', () => {
    class MyClass {
      isStarted = false;

      @hook('syncBefore', execute)
      start() {
        this.isStarted = true;
      }
    }

    const root = new Container({ tags: ['root'] });
    const instance = root.resolve(MyClass);

    beforeHooksRunner.execute(instance, { scope: root });

    expect(instance.isStarted).toBe(true);
  });

  it('should throw an error if runHooks is used for async hooks', async () => {
    class MyClass {
      instanciated = false;

      @hook('syncBefore', executeAsync)
      start() {
        this.instanciated = true;
      }
    }

    const root = new Container({ tags: ['root'] });
    const instance = root.resolve(MyClass);

    expect(() => beforeHooksRunner.execute(instance, { scope: root })).toThrowError(UnexpectedHookResultError);
  });

  it('should test hooks', () => {
    class Logger {
      isStarted = false;
      isDisposed = false;

      @onConstruct(execute)
      initialize(): void {
        this.isStarted = true;
      }

      @onDispose(execute)
      destroy(): void {
        this.isDisposed = true;
      }
    }

    const root = new Container({ tags: ['root'] })
      .useModule(new AddOnConstructHookModule())
      .useModule(new AddOnDisposeHookModule());

    const instance = root.resolve(Logger);
    root.dispose();

    expect(instance.isStarted).toBe(true);
    expect(instance.isDisposed).toBe(true);
  });

  const onStartHooksRunner = new HooksRunner('onStart');
  it('should test runHooksAsync', async () => {
    class Logger {
      isStarted = false;

      @hook('onStart', executeAsync)
      async initialize(@inject('TimeToSleep') timeToSleep: number) {
        await sleep(timeToSleep);
        this.isStarted = true;
      }

      @hook('onStart', executeAsync)
      async dispose(@inject('TimeToSleep') timeToSleep: number) {
        await sleep(timeToSleep);
        this.isStarted = false;
      }
    }

    const root = new Container({ tags: ['root'] }).addRegistration(R.fromValue(100).bindTo('TimeToSleep'));
    const instance = root.resolve(Logger);

    await onStartHooksRunner.executeAsync(instance, {
      scope: root,
      predicate: (methodName) => methodName === 'initialize',
    });

    expect(instance.isStarted).toBe(true);
    expect(hasHooks(instance, 'onStart')).toBe(true);
  });
});
import {
  AddOnConstructHookModule,
  AddOnDisposeHookModule,
  Container,
  hasHooks,
  hook,
  type HookFn,
  HooksRunner,
  inject,
  onConstruct,
  onDispose,
  Registration as R,
  UnexpectedHookResultError,
} from 'ts-ioc-container';

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

const execute: HookFn = (ctx) => {
  ctx.invokeMethod({ args: ctx.resolveArgs() });
};

const executeAsync: HookFn = async (ctx) => {
  await ctx.invokeMethod({ args: ctx.resolveArgs() });
};

const beforeHooksRunner = new HooksRunner('syncBefore');
describe('hooks', () => {
  it('should run runHooks only for sync hooks', () => {
    class MyClass {
      isStarted = false;

      @hook('syncBefore', execute)
      start() {
        this.isStarted = true;
      }
    }

    const root = new Container({ tags: ['root'] });
    const instance = root.resolve(MyClass);

    beforeHooksRunner.execute(instance, { scope: root });

    expect(instance.isStarted).toBe(true);
  });

  it('should throw an error if runHooks is used for async hooks', async () => {
    class MyClass {
      instanciated = false;

      @hook('syncBefore', executeAsync)
      start() {
        this.instanciated = true;
      }
    }

    const root = new Container({ tags: ['root'] });
    const instance = root.resolve(MyClass);

    expect(() => beforeHooksRunner.execute(instance, { scope: root })).toThrowError(UnexpectedHookResultError);
  });

  it('should test hooks', () => {
    class Logger {
      isStarted = false;
      isDisposed = false;

      @onConstruct(execute)
      initialize(): void {
        this.isStarted = true;
      }

      @onDispose(execute)
      destroy(): void {
        this.isDisposed = true;
      }
    }

    const root = new Container({ tags: ['root'] })
      .useModule(new AddOnConstructHookModule())
      .useModule(new AddOnDisposeHookModule());

    const instance = root.resolve(Logger);
    root.dispose();

    expect(instance.isStarted).toBe(true);
    expect(instance.isDisposed).toBe(true);
  });

  const onStartHooksRunner = new HooksRunner('onStart');
  it('should test runHooksAsync', async () => {
    class Logger {
      isStarted = false;

      @hook('onStart', executeAsync)
      async initialize(@inject('TimeToSleep') timeToSleep: number) {
        await sleep(timeToSleep);
        this.isStarted = true;
      }

      @hook('onStart', executeAsync)
      async dispose(@inject('TimeToSleep') timeToSleep: number) {
        await sleep(timeToSleep);
        this.isStarted = false;
      }
    }

    const root = new Container({ tags: ['root'] }).addRegistration(R.fromValue(100).bindTo('TimeToSleep'));
    const instance = root.resolve(Logger);

    await onStartHooksRunner.executeAsync(instance, {
      scope: root,
      predicate: (methodName) => methodName === 'initialize',
    });

    expect(instance.isStarted).toBe(true);
    expect(hasHooks(instance, 'onStart')).toBe(true);
  });
});

Hook Execution Methods

Hook Context

Hooks receive a HookContext that provides access to:

Best Practices