Reputation: 472
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:
Thanks.
Upvotes: 1
Views: 809
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
}
}
...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
}
}
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:
Using a rest argument with a varying tuple type
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.
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
}
}
The developer experience is very much like overloads, though.
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});
Upvotes: 2