Search code examples
javascripttypescripttypeswebsocketencapsulation

Encapsulate WebSocket Message in Typescript


I am trying to encapsulate websocket messages into well defined Type.
I have main IIncommingMessage which is the base interface for all incoming messages as such:

export interface IIncommingMessage {
  className : IClassName;
  methodName : IMethodName;
}

There are various types of class this websocket can call as follows:

export type IClassName = IClassA | IClassB | IClassC

as well as various method in associated classes

export type IMethodName = IfooinClassA | IbarinClassA | IbazinClassB | IquxinClassB | IquuxinClassB | IcorgeinClassC

Such that it looks like this

ClassA:
  foo()
  bar()

ClassB:
  baz()
  qux()
  quux()

ClassC:
  corge

The idea is that if a websocket message arrives. It'll come as

{
  className : "ClassB"
  methodName : "qux"
}

So this should call ClassB of function qux().

  1. The approach I'm taking looks bad. Was wondering if there is a better way to tightly couple the web-socket message to a well defined type

  2. Also curious on how i'll make this call in TypeScript - would it be protoype.call('className.method')?


Solution

  • About your first part to have well defined type this is how I would implement it.

    class ClassA {
        foo() { }
        bar() { }
    }
    
    class ClassB {
        baz() { }
        qux() { }
        quux() { }
    }
    
    class ClassC {
        corge() { }
        notAMethod = 1;
    }
    
    // This will be a like a dictionary mapping name to class
    // Will also be used in the second part.
    const Classes = {
        ClassA,
        ClassB,
        ClassC,
    };
    
    // This will be 'ClassA'|'ClassB'|'ClassC'
    type IClassName = keyof typeof Classes;
    type IClassOf<T extends IClassName> = InstanceType<typeof Classes[T]>;
    type MethodFilter<T extends IClassName> = { [MN in keyof IClassOf<T>]: IClassOf<T>[MN] extends () => void ? MN : never }
    type MethodName<T extends IClassName> = MethodFilter<T>[keyof MethodFilter<T>];
    
    interface IGenericIncomingMessage<T extends IClassName> {
        className: T;
        methodName: MethodName<T>;
    }
    
    type IIncomingMessage = IGenericIncomingMessage<'ClassA'> | IGenericIncomingMessage<'ClassB'> | IGenericIncomingMessage<'ClassC'>;
    
    let msg0: IIncomingMessage = {
        className: 'ClassA',
        methodName: 'foo', // valid
    }
    
    let msg1: IIncomingMessage = {
        className: 'ClassC',
        methodName: 'corge', // valid
    }
    
    let msg2: IIncomingMessage = { // compiler error. Type ... is not assignable to type 'IIncomingMessage'.
        className: 'ClassA',
        methodName: 'corge', 
    }
    
    let msg3: IIncomingMessage = {
        className: 'ClassD', // compiler error. ClassD Name is not not in 'ClassA' | 'ClassB' | 'ClassC'
        methodName: 'corge',
    }
    
    let msg4: IIncomingMessage = {
        className: 'ClassC',
        methodName: 'notAMethod', // compiler error. Type '"notAMethod"' is not assignable to type '"foo" | "bar" | "baz" | "qux" | "quux" | "corge"'.
    }
    

    So about the second part I use the Classes dictionary I defined earlier to lookup class by name, create a new instance of the class. This means that a malicious message having a valid class name that is not in the dictionary will not work.

    // I omit error handling here.
    function invokeFunction<T extends IClassName>(message: IGenericIncomingMessage<T>): void {
        // Look for the class instance in dictionary and create an instance
        const instance = new Classes[message.className]() as IClassOf<T>;
        // Find the method by name. The cast to any is to silence a compiler error;
        // You may need to perform additional login to validate that the method is allowed.
        const fn = instance[message.methodName] as unknown as () => void;
        fn.apply(instance);
    }