简体   繁体   中英

Why cannot a generic TypeScript function with an argument of type from a type union infer the same type as the return type?

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?

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

...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).

I have managed to come up with a solution using mapped types:

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

type Message = Messages[keyof Messages];

function exchangeMessage<T extends Message>(message: T): Required<Messages[T["type"]]> {
  return null as any;
}

const message = exchangeMessage({ type: 'a' });
message.optionalNumber;

Playground link

The duplication of type in the individual messages and the Message type union can be solved:

type Messages = {
  a: { requiredNumber: number; optionalNumber?: number; }
  b: { requiredString: number; optionalString?: string; }
};

type MessagesWithTypes = { [type in keyof Messages]: { type: type } & Messages[type] };
type Message = MessagesWithTypes[keyof MessagesWithTypes];

function exchangeMessage<T extends Message>(message: T): Required<Messages[T["type"]]> {
  return null as any;
}

const message = exchangeMessage({ type: 'a', requiredNumber: 0 });
message.requiredNumber;
message.optionalNumber;

Playground link

It checks all the boxes: I do not have to specify the input message type explicitly, it is inferred to be one of the types in the type union an the output type is pulled from the mapped type and made Required .

I believe this is a "third side" of the conditional return type / function overloads coin. All of these solutions require some sort of maintenance when adding a new message type, but I like this option best as the amount of maintenance is the smallest (one line to the mapped type - do not need to touch the function).

Initially I tried to find a way to derive the mapped type from the type union, something like so:

type Messages = { [type: typeof Message["type"]]: Message[type] }

But I don't think that is possible. It eventually lead me onto mapped types and the realization that I can derive the union from the mapped type as well, if I can't have it the other way around.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM