2

I've got a Message interface that takes a generic argument T and sets the value of T to be the type of an internal property called body:

interface Message<T> {
  body: T
}

I also have an EventHandler interface that describes a generic function that takes a Message as it's sole parameter:

interface EventHandler<T> {
  (event: Message<T>): void
}

Finally, I have an Integration class that has a subscribe method and a subscriptions map that both look like the following:

class Integration {
  public subscriptions: new Map<string, EventHandler>()
  protected subscribe<T>(eventName: string, handler: EventHandler<T>) {
    this.subscriptions.set(eventName, handler)
  }
}

Ultimately, I want the user to be able to define their own Integration instances like this:

interface MyEventProperties {
  foo: string
}

class MyIntegration extends Integration {
   constructor() {
     super()
     this.subscribe('My Event Name', this.myEventHandler)
   }
   myEventHandler(event: Message<MyEventProperties>) {
     // do something
   }
}

The problem is that this doesn't work. Because I don't know the generic type that ALL EventHandler functions will be getting I can't define the second generic parameter when instantiating the Map:

class Integration {
  public subscriptions: new Map<string, EventHandler>()
  // This errors with: Generic type 'EventHandler<T>' requires 1 type argument(s).

  protected subscribe<T>(eventName: string, handler: EventHandler<T>) {
    this.subscriptions.set(eventName, handler)
  }
}

My question is, am I approaching this problem incorrectly or am I missing something more obvious?

3
  • Create a base class for EventHandler that doesn't have a generic type argument. Use this in your subscriptions collection and EventHandler<T> in the public subscribe method. Commented Jan 21, 2019 at 19:38
  • another possibility is to default the generic to "never" or "unknown" in EventHandler so its no longer REQUIRED that it be passed in, i only skim read this article though. interface EventHandler<T = unknown> Commented Jan 21, 2019 at 20:27
  • @AndyLamb - so are you saying that the Map constructor look like this? new Map<string, (event: any) => void>() (where it can accept any argument as a parameter)? Commented Jan 21, 2019 at 21:02

1 Answer 1

3

The simple solution is to use unknown as the generic parameter in the map. The below solution will work:

interface Message<T> {
    body: T
}
interface EventHandler<T> {
    (event: Message<T>): void
}

class Integration {
    public subscriptions = new Map<string, EventHandler<unknown>>()
    protected subscribe<T>(eventName: string, handler: EventHandler<T>) {
        this.subscriptions.set(eventName, handler)
    }
    public publishEvent<T>(eventName: string, msg: Message<T>) {
        (this.subscriptions.get(eventName) as EventHandler<T>)(msg);
    }
}

interface MyEventProperties { foo: string }
class MyIntegration extends Integration {
    constructor() {
        super()
        this.subscribe('My Event Name', this.myEventHandler)
        this.subscribe('My Event Name Mistaken', this.myEventHandler) // no relation between name and event 
    }
    myEventHandler(event: Message<MyEventProperties>) {
        // do something
    }
}

The problem though is there is no relation between the event name and the type of the event. This may be a problem both when subscribing (as highlighted above) but also when emitting the event (the message body would not be checked against the expected message type)

I would suggest adding an mapping type between event type and name:

interface Message<T> {
    body: T
}
interface EventHandler<T> {
    (event: Message<T>): void
}

class Integration<TEvents> {
    public subscriptions = new Map<PropertyKey, EventHandler<unknown>>()
    protected subscribe<K extends keyof TEvents>(eventName: K, handler: EventHandler<TEvents[K]>) {
        this.subscriptions.set(eventName, handler)
    }
    public publishEvent<K extends keyof TEvents>(eventName: K, msg: Message<TEvents[K]>) {
        (this.subscriptions.get(eventName) as EventHandler<TEvents[K]>)(msg);
    }
}

interface MyEventProperties { foo: string }

class MyIntegration extends Integration<{
    'My Event Name': MyEventProperties
}> {
    constructor() {
        super()
        this.subscribe('My Event Name', this.myEventHandler)
        this.subscribe('My Event Name Mistaken', this.myEventHandler) // error now
        this.subscribe('My Event Name', (e: Message<string>) => { }) // error, type of body not ok  
    }
    myEventHandler(event: Message<MyEventProperties>) {
        // do something
    }
}

let m = new MyIntegration();
m.publishEvent("My Event Name", 0) // error body not ok 
m.publishEvent("My Event Name M") // error mistaken name
m.publishEvent("My Event Name", {
    body: { foo: ""}
}) // ok message and body correct
Sign up to request clarification or add additional context in comments.

1 Comment

Thanks you Titian! This is super helpful. I have something similar to the TEvents interface you've laid out so I'm good there. One remaining issue though is I'm trying to enforce that the generic argument in Message extends another specific type. So the declaration looks like this interface Message<T extends Foo> {}. I think that makes it impossible to define EventHandler as EventHandler<unknown> right? I suppose it's possible though to do EventHandler<T extends Foo | unknown>

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.