TypeScript Template Literals break generic constraints

Viewed 152

I am trying to write a Sub-Pub class that supports events AND sub-events denoted by the following syntax:

publisher.on("message:general", ... ) // subscribe to all messages
publisher.on("message", ... ) // subscribe to messages in general

To do this, I am using TypeScript template literals.

The issue is that while it broadly works, it seems to break generic constraints. Am I doing something wrong?

Here is what it looks like so far:

TS Playground link for convenience

interface ChatEvents {
  connect: {
    user: string;
  };
  disconnect: {
    user: string;
    reason: "banned" | "timeout" | "leave";
  };
}

declare type ChatEvent = keyof ChatEvents;

interface SubEvents extends Record<ChatEvent, string> {
  connect: "general" | "watercooler" | "lobby";
  disconnect: "voice" | "text";
}

declare type EventWithSubEvent<T extends ChatEvent> = `${T}${
  | ""
  | `:${SubEvents[T]}`}`;

// "connect" | "connect:general" | "connect:watercooler" | "connect:lobby"
declare type ChatConnectEvent = EventWithSubEvent<"connect">;
// "disconnect" | "disconnect:voice" | "disconnect:text"
declare type ChatDisconnectEvent = EventWithSubEvent<"disconnect">;

// So far so good!

// Let's write some generics:
declare function subscribeToEvent<E extends ChatEvent>(
  event: E,
  callback: (payload: ChatEvents[E]) => void
): void;

subscribeToEvent("connect", (payload) => {
    // Correctly extracts matching type
    type TPayload = typeof payload; // { user: string; }
});

declare function subscribeToSubEvent<E extends ChatEvent>(
  event: EventWithSubEvent<E>,
  callback: (payload: ChatEvents[E]) => void
): void;

subscribeToSubEvent("connect:general", (payload) => {
    // Extracts all possible payload types instead of only the `connect` one
    /*
        {
            user: string;
        } | {
            user: string;
            reason: "banned" | "timeout" | "leave";
        }
    */
    type TPayload = typeof payload;
});

subscribeToSubEvent("connect:voice", () => {}) // Should fail but doesn't. `:voice` is a subevent of `disconnect`, not `connect`.
2 Answers

I think I found the solution.

The problem is that apparently template types do not distribute over unions

To work around the issue take a look at this simplified playground

The trick is to specify the template type with generic type extending from any (just to force the union distribution)

declare type EventWithSubEvent<T extends ChatEvent> = T extends any ? `${T}:${SubEvents[T]}` : never;

This is your fixed playground

Generally I've found that the less typescript has to work to deduce a generic type from arguments, the more generics just work (especially for providing decent intellisense aid). In this case if you setup the generic to be the exact text passed as the first argument (constrained to all valid options) then use a helper type to extract just the 'connect' | 'disconnect' part to use in the callback it will work much smoother. (playground)

interface ChatEvents {
  connect: {
    user: string;
  };
  disconnect: {
    user: string;
    reason: "banned" | "timeout" | "leave";
  };
}
interface SubEvents extends Record<keyof ChatEvents, string> {
  connect: "general" | "watercooler" | "lobby";
  disconnect: "voice" | "text";
}
type AllChatEvents = {[K in keyof ChatEvents]: K | `${K}:${SubEvents[K]}`}[keyof ChatEvents]

// helper to extract the 'connect'|'disconnect' from an event
type ExtractEvt<T extends AllChatEvents> = T extends keyof ChatEvents ? T : T extends `${infer A}:${string}` ? A : never
declare function subscribeToSubEvent<E extends AllChatEvents>(
  event: E,
  callback: (payload: ChatEvents[ExtractEvt<E>]) => void
): void;

subscribeToSubEvent("disconnect:voice", (payload) => {
  console.log(payload.reason) // detects that 'disconnect' is the payload type
});

subscribeToSubEvent("connect:voice", () => {}) // fails properly 

As a bonus, because the generic constraint is all valid strings to be passed intellisense will give very useful results compared to other generics setups where it can't figure out what the correct behaviour is until you've already typed it:

showing auto-complete list between empty quotes as first argument to subscribeToSubEvent function and that it suggests all valid options

Related