In my system, I have established a method for transferring JSON messages through a socket connection. The communication involves Tagged Unions to categorize different message types:
export type ErrorMessage = { kind: 'error', errorMessage: ErrorData };
export type UserJoined = { kind: 'user-joined', user: UserData };
// etc
export type Message = ErrorMessage | UserJoined | /*etc*/;
While this setup is effective in the foundational code, I am now looking to enhance it within an additional module by introducing a new message type:
export type UserAction = { kind: 'user-action', action: Action }
The challenge arises when attempting to extend the existing "Message" union to include the new UserAction. One solution could be creating a separate extended message type:
export type ExtendedMessage = Message | UserAction;
However, this approach feels cumbersome and restrictive. It prevents passing UserAction as part of methods that expect a Message, even though logically it should function correctly. Moreover, future developers seeking to expand both modules will need to create yet another type like
export type ExtendedMessageAgain = ExtendedMessage | MyNewMessage
.
This situation prompted me to explore if there is a way to augment tagged unions across multiple modules without establishing a new type under a changed name or implementing a different design altogether.
I have researched various techniques used to extend interfaces with supplemental properties by introducing new .d.ts files, such as how passport extends Express JS's Request object for authentication functionalities. However, I have not come across a similar pattern for extending Tagged Unions in my searches, leading me to question the effectiveness of my current design.
My reluctance towards using classes stems from the fact that type information gets lost during data transmission, necessitating the existence of the kind
property. Despite the constraints, I value the provided 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'
Considering these factors, the only conceivable resolution I see involves transforming Message
into an interface foundation, illustrated here:
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
However, employing this method sacrifices the stringent type protections previously enjoyed.
With these considerations in mind, I am left wondering if there exists a technique to effectively extend Tagged Unions across multiple modules while preserving the original type name, devoid of creating a new definition. Alternatively, I remain open to exploring alternative design approaches that might offer a more elegant solution.
To better understand the context behind this discussion, you can refer to the following code: https://github.com/RonPenton/NotaMUD/blob/master/src/server/messages/index.ts
Ultimately, my goal is to streamline this structure by segregating all message components into distinct modules, preventing the current consolidated file from becoming unwieldy over time.