Home > Software engineering >  TypeScript union type function signatures incompatible
TypeScript union type function signatures incompatible

Time:01-31

I'm working on a TypeScript application using web sockets. I'm trying to set up code that can be reused between web browsers and Node.js, so I'm using the native WebSocket API for browsers and the ws library on Node. However, there are still some small API differences between the two, so I've declared the web socket variable to be of type WebSocket | import("ws") (with WebSocket being the web browser version). However, I get a type error when trying to call the addEventListener function:

const WebSocket = typeof window == "object" ? window.WebSocket : <typeof import("ws")>(require("ws"));
let socket = new WebSocket(url);
socket.addEventListener("message", event => { /* ... */ });

The error message is:

error TS2349: This expression is not callable.

Each member of the union type '{ (type: K, listener: (this: WebSocket, ev: WebSocketEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; (type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; } | { ...; }' has signatures, but none of those signatures are compatible with each other.

I pulled out the type declarations for both TypeScript's DOM library and the ws library and came up with this minimal proof-of-concept, which also fails on the addEventListener call (note that AddEventListenerOptions is a subtype of EventListenerOptions with a few added optional properties):

interface BrowserWS {
    addEventListener(type: "message", options?: boolean | AddEventListenerOptions): void;
    addEventListener(type: string, options?: boolean | AddEventListenerOptions): void;
}
interface NodeWS {
    addEventListener(type: "message", options?: EventListenerOptions): void;
    addEventListener(type: "close", options?: EventListenerOptions): void;
}
let s: BrowserWS | NodeWS = {addEventListener: () => {}};
s.addEventListener("message");

I'd assume this would work fine, since either addEventListener() call can accept undefined or a type conforming to AddEventListenerOptions, but it also fails with pretty much the same error on addEventListener (just the specific union type mentioned is different).

Why does this happen? Is there a type-safe workaround?

CodePudding user response:

If you have a value f whose type is a union of function types, and you have an list args of arguments, and if each member of the union would accept that argument list, then conceptually, f(...args) should be accepted by the compiler.

In practice, this is only partially supported and depends on the type of f. Before TypeScript 3.3, there was essentially no support and it would only work if the members of f were essentially identical.

With the improved behavior for calling union types as implemented in microsoft/TypeScript#29011, now you can call a union of function types with an intersection of their argument lists, but only if at most one union member is a generic function, and only if at most one union member is an overloaded function with multiple call signatures.

As described in the above linked release notes and pull request, these restrictions are there because it's not trivial to programmatically synthesize merged call signatures in the face of generics (where intersecting the function parameters would not play nicely with type argument inference) or in the face of multiple call signatures (where the number of resulting possible calls would grow very large for relatively modest unions). It's possible that there will be improvements in the future, but for now this is where the limits are.


In your case, the addEventListener property of the union BrowserWS | NodeWS is a union of function types, each of which is overloaded. Since more than one of the union members is overloaded, there is no support for calling that union.

As for workarounds, the only one I can think of is to manually synthesize the merged signatures ourselves, by following the rule I imagine the compiler would do if it could: intersect the parameter lists for every possible choice of call signature from each union member, and hope that there's no weird interaction with call signature order:

interface EitherWS {
    // BrowserWS 1/2 [type: "message", options?: boolean | AddEventListenerOptions]
    // NodeWS 1/2 [type: "message", options?: EventListenerOptions]
    // Joined [type: "message" & "message", 
    //    options?: (boolean | AddEventListenerOptions) & EventListenerOptions]
    addEventListener(type: "message", options?: AddEventListenerOptions): void;

    // BrowserWS 1/2 [type: "message", options?: boolean | AddEventListenerOptions]
    // NodeWS 2/2 [type: "close", options?: EventListenerOptions]
    // Joined [type: "message" & "close", 
    //    options?: AddEventListenerOptions] // impossible

    // BrowserWS 2/2 [type: string, options?: boolean | AddEventListenerOptions];
    // NodeWS 1/2 [type: "message", options?: EventListenerOptions]
    // Joined [type: string & "message", 
    //    options?: AddEventListenerOptions] // identical to first, ignore

    // BrowserWS 2/2 [type: string, options?: boolean | AddEventListenerOptions];
    // NodeWS 2/2 [type: "close", options?: EventListenerOptions]
    // Joined [type: string & "close", options?: EventListenerOptions]
    addEventListener(type: "close", options?: EventListenerOptions): void;
}

Okay, we have a new type with two call signatures. The compiler still won't let you call this:

let s: BrowserWS | NodeWS = { addEventListener: () => { } };
s.addEventListener("message"); // error!

But it will let you assign s to a new variable of type EitherWS:

let t: EitherWS = s; // this succeeds

which is indeed callable:

t.addEventListener("message"); // okay

It's not perfect; the compiler is unable to generate EitherWS from BrowserWS | NodeWS, but it can at least verify that the latter is assignable to the former. Thus the workaround is type safe, if a bit tedious.

Playground link to code

  • Related