Home > Net >  Why cannot a generic TypeScript function with an argument of type from a type union infer the same t
Why cannot a generic TypeScript function with an argument of type from a type union infer the same t

Time:07-10

type AMessage = { type: 'a'; optionalNumber?: number; };
type BMessage = { type: 'b'; optionalString?: string; };
type Message = AMessage | BMessage;

function exchangeMessage<T extends Message>(message: T): Required<T> {
  return null as any;
}

const message1 = exchangeMessage({ type: 'a' });

// `optionalNumber` is not accessible, `Required<{ type: "a"; }>` is inferred
message1.optionalNumber;

const message2 = exchangeMessage({ type: 'a' } as AMessage);
message2.optionalNumber;

export {};

Playground link

In this code, why must the argument be typed explicitly (as shown in the message2 line) in order for the return value to be the AMessage concrete type and why is Required<AMessage> not inferred (Required<{ type: "a"; }> is) as the only possible type the return value implicitly when the explicit type cast is not used (as shown in the message1 line)?

And how can I change this code so the generic function returns either of the union type member types depending on with which of those types the argument type is compatible?

CodePudding user response:

It's an inference problem. message can be of any type that is conforming to {type: string}. The compiler has no way to infer that to ONLY AMessage.

See this example to understand.

type AMessage = { type: 'a'; optionalNumber?: number; }
type BMessage = { type: 'b'; optionalString?: string; }
type Message = AMessage | BMessage;


async function exchangeMessage<T extends Message>(message: T): Promise<T> {
  return new Promise((resolve, reject) => { /* … */ });
}

const aMessage: AMessage = { type: 'a' };
const message = await exchangeMessage(aMessage);

message.optionalNumber; // Everything is fine

const aMessage2 = { type: 'a' } as const; // as const required to accepted as paramter 
const message2 = await exchangeMessage(aMessage2); // typed a  { readonly type: "a"; }

message2.optionalNumber; // problem

Playground


Edit Would a conditional return type match your needs ?

type AMessage = { type: 'a'; optionalNumber?: number; };
type BMessage = { type: 'b'; optionalString?: string; };
type Message = AMessage | BMessage;

declare function exchangeMessage<T extends Message>(message: T): Required<T extends AMessage ? AMessage :  BMessage>

const aMessage = exchangeMessage({ type: 'a' });
const bMessage = exchangeMessage({ type: 'b' }); 
const cMessage = exchangeMessage({ type: 'c' }); // NOPE 

aMessage.optionalNumber; // OK 
bMessage.optionalString; // OK

Playground

CodePudding user response:

...why must the argument be typed explicitly...

Because T extends Message just means that T has to be assignment-compatible with Message, not that it has to be (specifically) AMessage or BMessage. The type {type: "a"} is assignment-compatible with Message because it's assignment-compatible with AMessage. That doesn't mean it is an AMessage, just that it's compatible with it.

And how can I change this code so the generic function returns either of the union type member types depending on with which of those types the argument type is compatible?

You can do that using function overloads rather than generics (or, alternatively, a conditional return type as Matthieu Riegler points out):

function exchangeMessage(message: AMessage): Required<AMessage>;
function exchangeMessage(message: BMessage): Required<BMessage>;
function exchangeMessage(message: Message): Required<Message> {
    // ...implementation...
}

Playground link

Not using a generic means you're limiting what the function accepts more strictly. The downside is that if you add a third message type, you have to add a third overload (or edit your conditional return type, if using that solution).

  • Related