Typescript: Unwrap generic container to innter type by mapped enum

28 views Asked by At

I have messages that each have a type (encoded as a String) and data (an object). For each message type the data has the same properties, but it's different for every type. The types are all specified as enum values, and for each data format, there is a DTO class with a common supertype.

Minimal example:

enum MsgType {
    A="A",
    B="B"
}

interface MessageData {}

class MessageA implements MessageData {
    a : String = 1;
}

class MessageB implements MessageData {
    b : String = "2";
}

class MessageContainer <T extends MessageData = MessageData> {
    constructor (msgType : string, data : T) {
        this.data = data;
        this.msgType = msgType;
    }
    data : T;
    msgType : string;
}

I now want to create a Function that unwraps a MessageContainer<MessageData> into the correct subclass of MessageData if (and only if) it matches any MsgType in a specified MsgType[] AND the data matches a specific subclass of MessageData. If type or data format do not match, the function should return null.

Basically, this is the signature of the function I want:

function unwrapFiltered <T extends MessageData> (message : MessageContainer, msgTypes : MsgType[]) : T|null

with

  • T = MessageA for msgTypes = [MsgTypes.A],
  • T = MessageB for msgTypes = [MsgTypes.B] and
  • T = MessageA | MessageB for msgTypes = [MsgTypes.A, MsgTypes.B].

I have found a working solution that however requires me to have the mapping of MsgType to MessageData-SUbclasses twice (once as a type, and once as a value), which I consider overhead.

This is what I have:

type MessageTypeMap = {
    [MsgType.A] : MessageA,
    [MsgType.B] : MessageB
}
const MessageTypeMap = {
    [MsgType.A] : MessageA,
    [MsgType.B] : MessageB
}

function unwrapFilter<T extends MsgType> (message : MessageContainer, msgTypes : T[]) : MessageTypeMap[T]|null {
    for (let msgType of msgTypes) {
        let dataType = MessageTypeMap[msgType] as (new () => MessageTypeMap[T]);
        if (message.msgType === MsgType.A && message.data instanceof dataType) {
            return message.data;
        }
    }
    return null;
}

It can be used like so:

let a = unwrapFilter(new MessageContainer(MsgType.A, new MessageA), [MsgType.A]); 
// a : MessageA|null = {a:"1"}

let b = unwrapFilter(new MessageContainer(MsgType.A, new MessageB), [MsgType.A]); 
// b : MessageA|null = null

let c = unwrapFilter(new MessageContainer(MsgType.A, new MessageB), [MsgType.A, MsgType.B]);
// c : MessageA|MessageB|null = {b:"2"}

Is there any way to do this without either the type MessageTypeMap OR the const MessageTypeMap?

2

There are 2 answers

2
y2bd On BEST ANSWER

You definitely need const MessageTypeMap as you're doing runtime checks with the instanceof. It's straightforward to generate the "type" version of that map though using mapped types and InstanceOf<C>:

type MessageTypeMap = { 
  // get every key of the type of the mapping
  // this is equivalent to the enum in this case
  [P in keyof typeof MessageTypeMap]: 
    // (typeof MessageTypeMap)[MsgType.A] would return `typeof MessageA`, a type representing the class itself rather than a particular instance of the class
    // Using InstanceType<C> converts it to the instance type that class would produce, e.g. `MessageA`
    InstanceType<(typeof MessageTypeMap)[P]> 
};

Playground demonstrating that this works: Playground Link.

0
Johannes H. On

Thanks to the help of @y2bd in his answer and them pointing me to InstanceType<>, I was able to get rid of the type MessageTypeMap.

(I have also introduced a type alias for new () => T), to make it more readable)

type ConstructorType<T> = new () => T;
function unwrapFilter<T extends MsgType, U extends InstanceType<(typeof MessageTypeMap)[T]>> (message : MessageContainer, msgTypes : T[]) : U|null{
    for (let msgType of msgTypes) {
        let dataType = MessageTypeMap[msgType] as ConstructorType<U>;
        if (message.msgType === msgType && message.data instanceof dataType) {
            return message.data;
        }
    }
    return null;
}

Obviously further type aliases could be introduced to improve readability, but for the sake of this exampelm I'll leave it at that.