Typed Matter.js Events Wrapper in TypeScript

This article explains how to implement a strongly-typed wrapper around Matter.Events in Matter.js using TypeScript. While Matter.js provides built-in type definitions, its native event registration system (Matter.Events.on and Matter.Events.off) typically accepts generic string literals and loosely typed event payloads. By leveraging TypeScript generics, discriminated event maps, and mapped types, you can enforce strict compile-time checks on event names and automatically infer event payloads for engines, runners, and bodies.

The Problem with Native Matter.Events

In standard Matter.js, registering an event often looks like this:

Matter.Events.on(engine, 'collisionStart', (event) => {
  // 'event' is often typed as 'any' or generic IEvent, requiring manual casting
  console.log(event.pairs);
});

Using arbitrary strings introduces the risk of typos (e.g., 'colisionStart'), and the lack of contextual event typing requires repetitive type assertions.

Step 1: Define the Event Mapping Interfaces

Matter.js dispatches distinct events depending on the emitter instance (Matter.Engine, Matter.Runner, Matter.Composite, etc.). Start by defining event payload interfaces for the specific emitter:

import Matter from 'matter-js';

export interface EngineEventMap {
  beforeUpdate: Matter.IEventTimestamped<Matter.Engine>;
  afterUpdate: Matter.IEventTimestamped<Matter.Engine>;
  collisionStart: Matter.IEventCollision<Matter.Engine>;
  collisionActive: Matter.IEventCollision<Matter.Engine>;
  collisionEnd: Matter.IEventCollision<Matter.Engine>;
}

export interface RunnerEventMap {
  beforeTick: Matter.IEventTimestamped<Matter.Runner>;
  tick: Matter.IEventTimestamped<Matter.Runner>;
  afterTick: Matter.IEventTimestamped<Matter.Runner>;
}

Step 2: Create a Target-to-Event Map

Link each Matter.js emitter type to its corresponding event map using a generic mapping type:

export type MatterEventTargetMap = {
  [K in Matter.Engine as 'Engine']: EngineEventMap;
} & {
  [K in Matter.Runner as 'Runner']: RunnerEventMap;
};

export type TargetEvents<T> = 
  T extends Matter.Engine ? EngineEventMap :
  T extends Matter.Runner ? RunnerEventMap :
  Record<string, Matter.IEvent<T>>;

Step 3: Implement the Typed Wrapper Functions

Construct type-safe wrappers around Matter.Events.on and Matter.Events.off. By using conditional types, the wrapper automatically extracts the valid event names and associated event payloads based on the target object passed to it.

export class TypedEvents {
  /**
   * Subscribes a typed callback to an event on a given Matter target.
   */
  public static on<
    Target extends Matter.Engine | Matter.Runner,
    Events extends TargetEvents<Target>,
    EventName extends keyof Events & string
  >(
    target: Target,
    name: EventName,
    callback: (event: Events[EventName]) => void
  ): (event: Events[EventName]) => void {
    const wrappedHandler = callback as (e: unknown) => void;
    Matter.Events.on(target, name, wrappedHandler);
    return callback;
  }

  /**
   * Removes a previously registered typed callback.
   */
  public static off<
    Target extends Matter.Engine | Matter.Runner,
    Events extends TargetEvents<Target>,
    EventName extends keyof Events & string
  >(
    target: Target,
    name: EventName,
    callback: (event: Events[EventName]) => void
  ): void {
    const wrappedHandler = callback as (e: unknown) => void;
    Matter.Events.off(target, name, wrappedHandler);
  }

  /**
   * Triggers an event with strong payload verification.
   */
  public static trigger<
    Target extends Matter.Engine | Matter.Runner,
    Events extends TargetEvents<Target>,
    EventName extends keyof Events & string
  >(
    target: Target,
    name: EventName,
    event: Events[EventName]
  ): void {
    Matter.Events.trigger(target, name, event);
  }
}

Step 4: Practical Usage

With this implementation, the compiler validates the event name against the emitter instance and provides exact autocompletion for the payload properties:

const engine = Matter.Engine.create();

// Correct usage: TypeScript automatically types 'event' as IEventCollision<Engine>
TypedEvents.on(engine, 'collisionStart', (event) => {
  for (const pair of event.pairs) {
    console.log('Collision between:', pair.bodyA.id, pair.bodyB.id);
  }
});

// Compile Error: Typo in event name
// Argument of type '"collisionStrt"' is not assignable to parameter of type keyof EngineEventMap
TypedEvents.on(engine, 'collisionStrt', (event) => {});

// Compile Error: Invalid event for target type
// 'tick' does not exist on Matter.Engine
TypedEvents.on(engine, 'tick', (event) => {});

Removing Event Listeners

Because the function returns the callback, you retain a direct reference to unregister handlers when necessary:

const handleUpdate = (event: Matter.IEventTimestamped<Matter.Engine>) => {
  console.log('Engine updated at:', event.timestamp);
};

// Add listener
TypedEvents.on(engine, 'beforeUpdate', handleUpdate);

// Remove listener
TypedEvents.off(engine, 'beforeUpdate', handleUpdate);