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);