Search code examples
typescriptstrong-typingdiscriminated-union

Widen Tagged/Discriminated Union in TypeScript in a different module?


I have a system used for passing JSON messages back and forth over a socket connection. It uses Tagged Unions for the message types:

export type ErrorMessage = { kind: 'error', errorMessage: ErrorData };
export type UserJoined = { kind: 'user-joined', user: UserData };
// etc
export type Message = ErrorMessage | UserJoined | /*etc*/;

It works rather nicely in the base code, but I have a module built on top of it and I would like to extend the code. I have a new message type to add:

export type UserAction = { kind: 'user-action', action: Action }

Problem here is that I cannot extend "Message" to include my new UserAction into the union. I could make my own extended message, I suppose:

export type ExtendedMessage = Message | UserAction;

But the problem here is that seems clunky, number one. I can't pass my new UserAction into any methods expecting a Message, even though the code should actually work totally fine. And anyone else who later wants to expand both my module and the base module will need to create a third type: export type ExtendedMessageAgain = ExtendedMessage | MyNewMessage.

So. I've seen interfaces expanded with additional properties with the addition of new .d.ts files (like how passport extends Express JS's Request object to add authentication properties), I figured something like that has to exist for tagged unions too, right?

But that doesn't seem to be the case. I've googled all around and don't see this pattern in use anywhere. This leads me to believe that perhaps my design is faulty in some way. But I don't see a way around it.

I don't want to use classes because type information is erased over the wire; the kind property has to exist. And I like this paradigm:

declare var sendMessage = (message: Message) => void;
sendMessage( { kind: 'error', errorMessage: { /* */ } }); // ok
sendMessage( { kind: 'random', parameter: { /* */ } }); // error, no kind 'random'
sendMessage( { kind: 'error', message: { /* */ } }); // error, no property 'message' on 'error'

But the only solution to this I'm seeing is making Message an interface base, like this:

export interface Message { kind: string }
export interface ErrorMessage extends Message { errorMessage: ErrorData }

declare var sendMessage = (message: Message) => void;

sendMessage( { kind: 'error', errorMessage: { /* */ } }); // ok
sendMessage( { kind: 'random', parameter: { /* */ } }); // ok
sendMessage( { kind: 'error', message: { /* */ } }); // ok

And this method loses all of the nice type protections from above.

So... is there a way to extend Tagged Unions in multiple modules, affecting the original name of the type, and without defining a new type? Or is there a better design here that I'm just not seeing?

Here's the code that inspired this post: https://github.com/RonPenton/NotaMUD/blob/master/src/server/messages/index.ts

I'm looking to massively refactor that so I can move all the messages out into separate modules, instead of this one file growing to be an ungodly mess over time.


Solution

  • You can do this to define the union type of Message:

    export interface MessageTypes {}
    export type Message = MessageTypes[keyof MessageTypes]
    

    And then wherever you define a new message type, do this:

    export type UserAction = { kind: 'user-action', action: Action }
    
    declare module '../message' { // Where you define MessageTypes
      interface MessageTypes {
        UserAction: UserAction
      }
    }
    

    So the values of the MessageTypes interface become a union type, and you can add more values to the interface by using declaration merging, which will update the union type automatically.

    You can check the TS docs for more information about declaration merging: https://www.typescriptlang.org/docs/handbook/declaration-merging.html