Listeners Pattern

The Listeners pattern manages multiple event handlers and notifies them with error isolation. When a listener throws an error, other listeners continue executing.

Type Definition

/**
 * Creates a listener management system for registering multiple event handlers and 
 * notifying them asynchronously with error isolation.
 * 
 * @template T - Tuple type representing the arguments passed to listeners
 * @template E - Type of errors that can be thrown by listeners (defaults to unknown)
 * @param onError - Optional error handler called when a listener throws an error
 * @returns Tuple containing [addListener, notifyListeners] functions
 */
function newListeners<T extends unknown[], E = unknown>(
  onError?: (error: E) => void,
): [
  /** 
   * Registers a new listener function.
   * @param listener - Function to call when notifying listeners
   * @returns Cleanup function to remove this specific listener
   */
  addListener: (listener: (...args: T) => void | Promise<void>) => () => void,
  
  /** 
   * Notifies all registered listeners with the provided arguments.
   * Continues execution even if individual listeners throw errors.
   * @param args - Arguments to pass to all listeners
   */
  notifyListeners: (...args: T) => Promise<void>,
];

Features:

Usage Patterns

Basic event management:

const [addListener, notifyListeners] = newListeners<[string, number]>();

// Register listeners
const removeListener1 = addListener((message, count) => {
  console.log(`Listener 1: ${message} (${count})`);
});

const removeListener2 = addListener(async (message, count) => {
  await delay(100);
  console.log(`Listener 2: ${message} (${count})`);
});

// Notify all listeners
await notifyListeners("Hello", 42);

// Cleanup
removeListener1();
removeListener2();

Reactive data model:


class UserModel {
  private _users: User[] = [];
  addListener: (listener: (users: User[]) => void) => () => void;
  protected notifyListeners: (users: User[]) => Promise<void>;
  constructor() {
    [this.addListener, this.notifyListeners] = newListeners<[User[]]>();
  }
  
  get users() {
    return [...this._users];
  }
  
  addUser(user: User) {
    this._users.push(user);
    this.notifyListeners(this.users);
  }
  
  removeUser(id: string) {
    this._users = this._users.filter(u => u.id !== id);
    this.notifyListeners(this.users);
  }
  
  onChange(listener: (users: User[]) => void) {
    return this.addListener(listener);
  }
}

// Usage
const userModel = new UserModel();
const unsubscribe = userModel.onChange((users) => {
  console.log('Users updated:', users.length);
});

userModel.addUser({ id: '1', name: 'Alice' });
unsubscribe();

Error handling:

const [addListener, notifyListeners] = newListeners<[string]>((error) => {
  console.error('Listener error:', error.message);
  errorTracker.report(error);
});

// Register listeners that might fail
addListener((message) => {
  if (message === 'error') {
    throw new Error('Simulated error');
  }
  console.log('Good listener:', message);
});

addListener((message) => {
  console.log('Always works:', message);
});

// Both listeners are called, error is handled
await notifyListeners('error');

Basic Implementation

function newListeners<T extends unknown[], E = unknown>(
  onError: (error: E) => void = console.error,
): [
  addListener: (listener: (...args: T) => void | Promise<void>) => () => void,
  notifyListeners: (...args: T) => Promise<void>,
] {
  const listeners: {
    [listenerId: number]: (...args: T) => Promise<void>;
  } = {};
  let listenerId = 0;
  
  function addListener(
    listener: (...args: T) => void | Promise<void>,
  ): () => void {
    const id = listenerId++;
    listeners[id] = async (...args: T) => {
      try {
        await listener?.(...args);
      } catch (e) {
        onError(e as E);
      }
    }
    return () => {
      delete listeners[id];
    };
  }
  
  async function notifyListeners(...args: T): Promise<void> {
    for (const listener of Object.values(listeners)) {
      await listener(...args);
    }
  }
  
  return [addListener, notifyListeners];
}