Search code examples
typescripttypescript-genericstypescript-types

Typescript: Unwrap generic container to innter type by mapped enum


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?


Solution

  • 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.