Search code examples
typescript

Passing typed arguments to generic callback type


I have an Observer that allows passing a payload and doesn’t care about the arguments passed to the callback. At the same time, I want to type the arguments when creating the callback so that I can access them without using any. Unknown doesn’t allow passing typed arguments.

The link includes part of the current Observer implementation. The issue with generics is that the ObservableObj interface is used when the constructor is called, at which point it doesn’t yet know the types of the arguments that will be passed to the callbacks.

Playground

and the code itself:

interface ObservableObj {
  id: string;
  once: boolean;
  callback: (data?: unknown) => void;
}

export default class Observable {
  private listeners: Map<string, Array<ObservableObj>>;

  constructor() {
    this.listeners = new Map<string, Array<ObservableObj>>();
  }

  public on(
    eventType: string,
    callback: (data?: unknown) => void,
    once = false
  ): { id: string; eventType: string } {
    if (!this.listeners.get(eventType)) {
      this.listeners.set(eventType, []);
    }

    const id = this.generateId();
    this.listeners.get(eventType)?.push({ id, callback, once });
    return { id, eventType };
  }

  public fire(eventType: string, data: unknown) {
    if (!(this.listeners.get(eventType) && this.listeners.get(eventType)!.length > 0)) {
      return;
    }
    const listeners = this.listeners.get(eventType);
    let i = listeners!.length - 1;
    while (i >= 0) {
      const f = listeners![i].callback;
      if (listeners![i].once) {
        listeners!.splice(i, 1);
      }
      i--;
      f(data);
    }
  }

  private generateId(): string {
    return Math.random().toString(36).substring(2, 9);
  }
}

const observable = new Observable();
observable.on('test-event', (data: {prop: boolean}) => console.log(data.prop));
observable.fire('test-event', {prop: true});


Solution

  • The only way this could work and behave the way you want is to use generics. The call to observable.on(eventName, callback) has to either return something whose fire() method cares only accepts arguments that callback accepts, or it has to change the type of observable to be something that does that to fire(). Either way, ObservableObj simply must be generic:

    interface ObservableObj<T> {
      id: string;
      once: boolean;
      callback: (data: T) => void;
    }
    

    It looks like you're expecting the situation where observable itself has some state that updates every time you call on(). This is technically possible to do if on() is an assertion method and if the subsequent change to observable is considered a narrowing, although it's somewhat limited and fragile. It might look like this:

    declare class Observable<T extends object = {}> {
        __x: T; // need some structural dependency on T
        constructor();
        on<K extends string, V>(
          eventType: K, callback: (data: V) => void, once?: boolean
        ): asserts this is Observable<T & Record<K, V>>;
        fire<K extends string & keyof T>(eventType: K, data: T[K]): void;
    }
    

    Note that the implementation of Observable is out of scope here, since we mostly care about types. So I just use declare class above.

    Here Observable is generic in T, an object type that maps keys (the eventType type) to values (the corresponding data type). I needed to add a virtual property of type T to the class instance type (it doesn't have to exist at runtime) to convince TypeScript that Observable<T> actually depends structurally on T.

    When you create a new Observable, the type of T defaults to the empty object type. When you call on, TypeScript will narrow this from Observable<T> to Observable<T & Record<K, V>>. The intersection of T with Record<K, V> means that T is effectively augmented with a new property whose key is K (the type of eventType) and whose value is V (the type of the data expected by callback).

    Then fire() only accepts eventTypes in keyof T, and the data must be the corresponding property type T[K] (the indexed access type).

    Let's test that out:

    const observable: Observable = new Observable();
    //              ^^^^^^^^^^^^
    // need to annotate this
    
    observable.fire('test-event', { prop: true }); // error!
    //              ~~~~~~~~~~~~
    // Argument of type 'string' is not assignable to parameter of type 'never'.
    
    observable.on('test-event', (data: { prop: boolean }) => console.log(data.prop));
    
    observable.fire('test-event', { prop: true }); // okay
    try {
      observable.fire('test-event', null); // error!
      //                            ~~~~
      // Argument of type 'null' is not assignable to parameter of type '{ prop: boolean; }'
    } catch (e) {
      console.log(e) 
    }
    

    This works as you expect. If I call fire() before on(), TypeScript complains that "test-event" is unexpected. Once I call on() with test-event and a callback expecting {prop: boolean}, then I can call fire() that way. If I try to call fire() with null instead of {prop: boolean}, I get an error.

    This is all well and good but it's fragile. First, you need to annotate observable as having type Observable. If you don't do that, then on() will fail to act like an assertion method. It's a limitation of assertion methods that they have to be explicitly annotated as such.

    And having a single variable whose state changes is tough, because it means things depend on the order of your calls, and if you pass that variable into functions, then TypeScript can't necessarily tell which things happen when and so your narrowings can reset.

    And you can't return anything from an assertion method. So the {id, eventType} receipt you used to return from on() is absent now.


    It would be easier for TypeScript to handle if your types don't need to track state. You could do this by, for example, having on() return a new Observable that has the added thing in it (and it can return a receipt also, or whatever you want):

    declare class Observable<T extends object = {}> {
        __x: T; // need some structural dependency on T
        constructor();
        on<K extends string, V>(
          eventType: K, callback: (data: V) => void, once?: boolean
        ): Observable<T & Record<K, V>>;
        fire<K extends string & keyof T>(eventType: K, data: T[K]): void;
    }
    

    And then when you call on() you capture the return value as your new observable:

    const o = new Observable();
    
    o.fire('test-event', { prop: true }); // error!
    //     ~~~~~~~~~~~~
    // Argument of type 'string' is not assignable to parameter of type 'never'.
    
    const o1 = o.on('test-event', (data: { prop: boolean }) => console.log(data.prop));
    o1.fire('test-event', { prop: true }); // okay
    try {
      o1.fire('test-event', null); // error!
      //                            ~~~~
      // Argument of type 'null' is not assignable to parameter of type '{ prop: boolean; }'
    } catch (e) {
      console.log(e)
    }
    

    This is effectively stateless. You can pass o1 into any function you want and rest assured that it will remember 'test-event'.


    Anyway, there are lots of different changes one might make here and this answer is already getting too long. The main point is that if you want to keep track of the types of eventName and the corresponding data, there is no alternative but to use generics.

    Playground link to code