typescript

Can a type that has an object format have a conditional type as a value based on another index of itself?


I have searched in a lot of ways but I am not able to find more information about conditional typing, I am trying to create a type called ChatItemMessage and it has 2 indexes, type and value.

This is the current code:

type ChatItemMessage = {
    type: ChatItemMessageType;
    value: ChatMessage | ChatDate;
};

enum ChatItemMessageType {
    Date = "date",
    Message = "message",
}

type ChatMessage = {
    id: number;
};

type ChatDate = {
    date: string;
};

What I am trying to do is conditionally set the type of value based on the value of type:

export type ChatItemMessage = {
    type: ChatItemMessageType;
    value: (ChatItemMessage['type'] extends ChatItemMessageType.Message ? ChatMessage : ChatDate);
};

This is not working, I also tried (and other variations):

export type ChatItemMessage<T extends ChatItemMessageType> = {
    type: T;
    value: (T extends ChatItemMessageType.Message ? ChatMessage : ChatDate);
};

In my code I am then doing:

declare const data: ChatItemMessage[]
data.map(
  message => message.type === ChatItemMessageType.Date ?
    message.value.date : // error!
    //            ~~~~ <--   Property 'date' does not exist on type 'ChatMessage'.
    message.value.id.toFixed() // error!
    //            ~~ <--   Property 'id' does not exist on type 'ChatDate'.
);

What I want is for message.value to be ChatMessage because I already checked if message.type === ChatItemMessageType.Message, but the IDE is always saying it is ChatDate.

The idea is for the IDE and any dev to know that if type = ChatItemMessageType.Message then value = ChatMessage, else (in this case) it will be ChatDate, so new errors should show up if the dev has the wrong type in mind at runtime.

I am not even sure if it can be achieved or not. If you need more info, let me know!


Solution

  • For this to work you need ChatItemMessage to be a discriminated union, where each member of the union corresponds to one of the possibilities for type/value pairings, so that type can be used as a discriminant property. Like this:

    type ChatItemMessage =
      { type: ChatItemMessageType.Date; value: ChatDate; } |
      { type: ChatItemMessageType.Message, value: ChatMessage; }
    

    Once you do that your code will work as written:

    data.map(
      message => message.type === ChatItemMessageType.Date ?
        message.value.date : // okay
        message.value.id.toFixed() // okay
    );
    

    Playground link to code