Is there a way to coerce a type to dynamically be the same using generics in Typescript?

Viewed 79

I'm trying to create a pattern for event handling in a class and it involved a function that quickly sets up an event for a class. It takes a function that can take any number of arguments and return void and a key to an object that has arrays of these functions. I'd like to use a generic to ensure the arguments of the function passed is the same as the function type in the array grabbed using the key. So if the T that is passed is () => void, then this.eventListeners[key] should also be of type T.

Perhaps this is impossible with Typescript as it is. In C++ I would pass the key through the template to ensure type safety, can this be done in Typescript?

type TonActionListener = (_:Action) => void;                                                                                                                                                                                                                                  
type TonDisconnectListener = () => void;                                                                                                                                                                                                                                      
type IeventListeners = {                                                                                                                                                                                                                                                      
  onAction: Array<TonActionListener>;                                                                                                                                                                                                                                         
  onDisconnect: Array<TonDisconnectListener>;                                                                                                                                                                                                                                 
} 

export default class ControllerConnection {  
  protected eventListeners = {                                                                                                                                                                                                                                                
    onAction: [],                                                                                                                                                                                                                                                             
    onDisconnect: [],                                                                                                                                                                                                                                                         
  } as IeventListeners;
  // add an event listener by key and return a callback to remove that event listener.                                                                                                                                                                                        
  protected addEventListener<T>(key: (keyof IeventListeners), eventListener:T): (() => void) {                                                                                                                                                                                
    (this.eventListeners[key] as Array<T>).push(eventListener);                                                                                                                                                                                                               
    return () => void ((this.eventListeners[key] as Array<T>) = (this.eventListeners[key] as Array<T>).filter((l:T):boolean => l != eventListener));                                                                                                                          
  }                                                                                                                                                                                                                                                                           
  onAction:any = (onActionListener: ((_:Action) => void)) => 
  this.addEventListener('onAction', onActionListener);                                                                                                                                                             
  onDisconnect:any = (onDisconnectListener: (() => void)) => 
  this.addEventListener('onDisconnect', onDisconnectListener);
}

EDIT: So judging by this github issue, Typescript is turing complete, so there is definitely a way to achieve what i want. We just need to figure out how. https://github.com/Microsoft/TypeScript/issues/14833

EDIT: I've gotten far enough to create the necessary types, but I'm not sure how to write the function:

type IListeners = {
    onAction: Array<(_: string) => void>;
    onDisconnect: Array<() => void>;
}

type TKeyed<T extends keyof IListeners> = IListeners[T];

type TEventListener<Tk extends keyof IListeners> = <Tl extends TKeyed<Tk>>(listener: Tl) => void;

// "Tk is not defined", but I want the caller of addListener to define Tk
const addListener:TEventListener<Tk> = listener:Tk => {
    // add listener to array here. Not important for this question.
}

const onAction: TEventListener<"onAction"> = addListener(str => void str + 'hello');

EDIT: I'm closer... I just need to get addListener to accept that Tk well be passed by the caller. Instead of being defined right away. I updated the above piece of code.

1 Answers

It wasn't perfect, but I got close. The type-checking works, but you need to specify the type twice, which isn't dry. Fortunately because the type-checking works, both types well be checked to be the same.

type IListener = {
    [ key in (keyof typeof MyClass.prototype.listeners) ]: typeof MyClass.prototype.listeners[key][0];
}

class MyClass {
    listeners = {
        onAction: [] as Array<(_: string) => void>,
        onDisconnect: [] as Array<() => void>,
        //etc...
    };

    addListener = <Tk extends keyof IListener, Tl extends IListener[Tk]>(listener: Tl, key: Tk) => {
        (this.listeners[key] as Array<Tl>).push(listener);
        return () => {
            (this.listeners[key] as Array<Tl>) = (this.listeners[key] as Array<Tl>).filter((l:Tl):boolean => l !== listener);
        };
    };

    onAction = (listener: IListener["onAction"]):VoidOp => this.addListener(listener, "onAction");
    onDisconnect = (listener: IListener["onDisconnect"]):VoidOp => this.addListener(listener, "onDisconnect");
}

In the end I ended up using a different method anyways. I have a playground with all of my thoughts in code following. You can check it out if you're curious:

https://typescript-play.js.org/#code/C4TwDgpgBAag9gSwCYHkxQLxQBQEpMB8UAbokgNwBQ1A9DVCgAoAqAkigHJQCMUd1oSFFYAZBAGdgEAHYQATpigBvSlDVQA2lADWEEFATScukHABmUQRHNQAsiADCAGwCG48QDowcuMF-gIDycJKVk5cXwAXQAuSwCbe2c3T29ff0ggkJl5cQ0TSI0ABkiqAF9qAGNXdztHavFlVXVgyWzwxRV1Lqg4aQBBCuAEXtiNSKg3KD65ORcQAB5sAH1YyTlDAHN8DCJSZAIAGibunukAEQkK3tlB0fHJ6dmFvEISMkPj7roIYAqPf8+pSonxcSCQYlaYUU82Y2igEAAHqEkA0TDZRFkwgcoMwnPCkTIUcIIaF5BpYZECNgWqS5LFcdiTPTtNsiJ0TjhgAALCSZSE5PJ6e4NR5zGFOAi4LwAV3EXOpmPkuCoHKgch+0rkRheO0aqq62G5vJpbVy+QmIpmYtxksUhp5nhNYTNQotUytCxtUrMCCcUjk2Gp0VxuGiACM4HAnBAXEZdXiAIQYLBOpUqjlAwHArq9AZDXp21N04mKuQaABEucGw2k5ciofgyDQryNnlB4NLCv5cmxlf61d65eVn16F3EV2kN2AhdLsQx3Yro8u1wggzrDbIzd1rY87ZJbS7tN7S-HK7Xw-KlDoUAAohwzgwWOwuLx+Ff6Ew2JwoAAmPg0AQAhLbsGiwdk1D7PMa3LUY1k2EpPj7McJynGDNAQy8qmSWokncP9wPhYgZGAG8AFsEGAf1FFkAB3W8iOkEjyMo+R5nnWlxCpYcczjSwHQ8CAGKYij-Q8Xoyloeg7wfT9n1-f9qEoaNp1IxxqIgOjEnqH88CoVSHDE6RsAAcirfNpGM7FsDWaI4OkLZXgncQo0CJw4A2azgDkXBhyUn4oH0+p1M0upkl06hAuSQyoN6QMXAHaRbK8zZQzeZBHN6ZzoyCdyTK5CAnDc4yfL00L3EM5Cz2AQNUr2JAMukLLXNy4z8sKuBit8685TgaUnHq+QfDkDxKEi8qTxQ1dquweLzKS9Z7NqsgGqanKPNagqipKySoAAWn2g7DuoREwDgORpywmobyEsiRNY5h8WRBolE0OFDCgABpPpCpiCZpBAMYoFKbEvsKx7CVRPQbAerA0QsZg2SaItxFiWwXDAeZQacKzsH+Xc5A2FGcQ0LH61eOrcDGIgsFoux0fCpoaAAKiZ44magABlYAXHOhonU2Sw4D+wjiJG9R2YAATAHmXFIkXGL46BBOIwWoCdQWxbUSXpdmOWiygMxpWkBLVbDaAKhcQqIHqmj8qMWM+IkeXpydiBmKkJBNagSX1WATUjAtwqw3iuE-DVN24CIxXnbV0s2YAtRekx8HpCJLGiGwZXGNiD7sSLWJcf+HnCfpEmyd1CmC9ZNL6oIpzpxpO0d2RjwNh+TOhPwAAfLv0KlcRggqCBwq6euDFTxFFBpIIZA2bl0zUaewFleUi249Rff9nBq+n8QwEH4fDCQRF18vNRmdZ8Xb2YhoHaz6cw8tvEKk1dVGKcfRxGlMNx3WM36pbmzKAUsZZy3vtHcBYc3YUS9trUBEwCa33+ggjY0pSLEQaOqAAjtKBA6p6pmDOhAoSsCE7wmYsnRET1PrfQlDge+OdsR42LkTZgZd8AEUIQobAY99Y2GbqWTwbdqr327r3MYHDPiL07MwxBuBARNHKKUIAA

Related