no_stack_dub_sack
no_stack_dub_sack

Reputation: 472

TypeScript class method overloads not behaving the same as function overloads

Apologies in advance if this is a duplicate, but my search has not turned up anything that quite fits the issue I'm having.

Firstly, the desired behavior is to have a class method with two params, the second optional, where the second param's type is dependent on the first param's type. If the first param is type A, the second param should always be required and should be of type X, if the first param is type B, the second param should be omitted.

I've achieved something quite like this with function overloading:

// types
enum MessageType { FOO, BAR, BAZ }

type MessagePayload<T extends MessageType> = T extends MessageType.FOO 
    ? string
    : T extends MessageType.BAR
    ? number
    : never;

// overloads
function sendMessage<T extends MessageType.BAZ>(action: T): void

function sendMessage<T extends MessageType>(action: T, payload: MessagePayload<T>): void

// implementation
function sendMessage<T extends MessageType>(action: T, payload?: MessagePayload<T>) {
  // do something
}

// tests
sendMessage(MessageType.FOO, "10") // no error - as expected
sendMessage(MessageType.FOO, 10)   // error - as expected, payload is not string
sendMessage(MessageType.FOO)       // error - as expected, payload must be string
sendMessage(MessageType.BAZ);      // no error - as expected - since MessageType is BAZ

However, the same exact constructs, when applied to a class method, do not produce the same results. This snippet is a continuation of the first and uses the same types:

// interface
interface ISomeClient {
  sendMessage<T extends MessageType.BAZ>(action: T): void

  sendMessage<T extends MessageType>(action: T, payload: MessagePayload<T>): void
}

// implementation
class SomeClient implements ISomeClient {
  sendMessage<T extends MessageType>(action: T, payload?: MessagePayload<T>) {
    // do something
  }
}

// tests
const client = new SomeClient();

client.sendMessage(MessageType.FOO, "10"); // no error - as expected
client.sendMessage(MessageType.FOO, 10);   // error, payload is not string
client.sendMessage(MessageType.FOO)        // no error??? different behavior than function example
client.sendMessage(MessageType.BAZ);       // this part works fine

Here is a more complete example on the TS Playgound.

So, I guess this is a two-parter:

  1. why is this not working for the class example?
  2. is there some better way to achieve this that will work for both classes and functions and that doesn't require maintaining an overload to capture the types that don't require a payload? I've used an enum and a conditional type here to constrain the second param to match what's expected given the first param. I've played around with another way involving an key to type map but it seems hacky, still requires overloads, and suffers from this same issue for classes and functions.

Thanks.

Upvotes: 1

Views: 809

Answers (1)

T.J. Crowder
T.J. Crowder

Reputation: 1075009

1. why is this not working for the class example?

I think the issue is that the signature in the class isn't being treated as just an implementation signature like the third standalone function signature is, because the overloads are declared separately. So the class is augmenting those, adding a third public signature, in contrast to the function overloads where the third signature is not public, it's just the implementation signature.

You can fix it by not putting the overloads (just) in the interface declaration. Either don't use an interface:

class SomeClient {
  sendMessage<T extends MessageType.QAT | MessageType.QAZ>(action: T): void;
  sendMessage<T extends MessageType>(action: T, payload: MessagePayload<T>): void;
  sendMessage<T extends MessageType>(action: T, payload?: MessagePayload<T>) {
    // do something
  }
}

Playground example

...or do use an interface, but also repeat the overloads in the class construct so TypeScript knows that the third one is an implementation signature:

interface ISomeClient {
  sendMessage<T extends MessageType.QAT | MessageType.QAZ>(action: T): void
  sendMessage<T extends MessageType>(action: T, payload: MessagePayload<T>): void
}

class SomeClient implements ISomeClient {
  sendMessage<T extends MessageType.QAT | MessageType.QAZ>(action: T): void
  sendMessage<T extends MessageType>(action: T, payload: MessagePayload<T>): void
  sendMessage<T extends MessageType>(action: T, payload?: MessagePayload<T>) {
    // do something
  }
}

Playground example

That's repetitive, but I'm not sure there's a way around it other than assigning to SomeClient.prototype after-the-fact.

2. is there some better way to achieve this...

I tend to like function overloads for this, but it's true that they don't work for everything and if you have lots of these, it'll get unwieldy fast.

I should note that I'm still just at the journeyman level with TypeScript so there may be other options, but I can think of two alternatives:

  1. Using a rest argument with a varying tuple type

  2. Using a discriminated union so there's always only one parameter

In places I've found function overloads to be too cumbersome, I've tended toward discriminated unions, but the tuple idea was kind of cute so I thought I'd include it.

Rest + Tuple

Instead of MessagePayload<T>, you have MessageParams<T> defining a tuple based on T:

type MessageParams<T extends MessageType> = T extends MessageType.FOO 
    ? [T, string]
    : T extends MessageType.BAR
    ? [T, number]
    : T extends MessageType.BAZ
    ? [T, User]
    : [T];

(If you need MessagePayload<T> for other reasons, you can derive it from the above: type MessagePayload2<T extends MessageType> = MessageParams<T>[1];.)

Then the method uses that as the type of a rest parameter:

class SomeClient {
  sendMessage<T extends MessageType>(...args: MessageParams<T>) {
    const action = args[0];
    // do something
  }
}

Playground example

The developer experience is very much like overloads, though.

Discriminated Union

This last option is a bigger change: You don't have separate parameters at all, just a single object type that's a discriminated union:

type FOOMessage = {action: MessageType.FOO; payload: string;};
type BARMessage = {action: MessageType.BAR; payload: number;};
type BAZMessage = {action: MessageType.BAZ; payload: User;};
type OtherMessage = {action: Exclude<MessageType, MessageType.FOO | MessageType.BAR | MessageType.BAZ>;};
// `OtherMessage` is the catch-all for all message types other than the
// ones with their own interface, note the use of `Exclude`

type Message = FOOMessage | BARMessage | BAZMessage | OtherMessage;

// ...

class SomeClient {
  sendMessage(message: Message) {
    const action = message.action;
    // do something
  }
}

The calls to it change to passing an object:

// tests
client.sendMessage({action: MessageType.FOO, payload: "string"});
client.sendMessage({action: MessageType.FOO}); // Error as desired
client.sendMessage({action: MessageType.QAT});

Playground example

Upvotes: 2

Related Questions