I have a JavaScript JSDoc annotated function type-checked using TypeScript.
It takes an object of one of the types of a given type union (Message
). The union types have some optional fields (optionalNumber
for AMessage
, optionalString
for BMessage
).
The function returns an object of the same type. I use @template
to annotate that. Additionally, the optional fields are defined in the result. I use Required
to annotate that:
/** @typedef {{ type: 'a'; optionalNumber?: number; }} AMessage */
/** @typedef {{ type: 'b'; optionalString?: string; }} BMessage */
/** @typedef {AMessage | BMessage} Message */
/**
* @template {Message} T
* @param {T} message
* @returns {Promise<Required<T>>}
*/
async function exchangeMessage(message) {
return new Promise((resolve, reject) => { /* … */ });
}
// `message` = `Required<{ type: "a"; }>` here (Intellisense on hover)
const message = await exchangeMessage({ type: 'a' });
// TODO: Why is this not accessible?
message.optionalNumber;
export {};
However, when accessing the result, I only see the fields of the type that are marked as required in the type union and thus are guaranteed to be present in the function argument object.
I do not see the fields that are optional in the type in the type union even though my types say those are present in the result using the Required
utility type.
Why doesn't the type resolution logic in TypeScript surface these optional-turned-required fields in the result object?
TypeScript Playground with TSConfig setting switched to JavaScript.
The same problem can be demonstrated in TypeScript: Playground.
A solution appears to be to pass in the object explicitly typed as one of the types in the type union. However, I don't understand why TypeScript cannot infer this type implicitly as there is enough information to single out the only possible concrete type the argument and the return value could be.
CodePudding user response:
You'll need a specification on your generic & narrowing then :
/** @typedef {{ type: 'a'; optionalNumber?: number; }} AMessage */
/** @typedef {{ type: 'b'; optionalString?: string; }} BMessage */
/** @typedef {AMessage | BMessage} Message */
/**
* @template {Message} T extends Message
* @param {Message} message
* @returns {Promise<Required<T>>}
*/
async function exchangeMessage(message) {
return new Promise((resolve, reject) => { /* … */ });
}
const message = await exchangeMessage({ type: 'a' });
if (message.type === 'a') {
message.optionalNumber; // AMessage
} else {
message.optionalString; // BMessage
}
You'll not have that probleme with TS :
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
const message2 = await exchangeMessage(aMessage2); // typed a { readonly type: "a"; }
message.optionalNumber;
CodePudding user response:
Solution based on mapped types:
/** @typedef {{ type: 'a'; optionalNumber?: number; }} AMessage */
/** @typedef {{ type: 'b'; optionalString?: string; }} BMessage */
/** @typedef {{ a: AMessage; b: BMessage; }} Messages */
/** @typedef {Messages[keyof Messages]} Message */
/**
* @template {Message} T
* @param {T} message
* @returns {Promise<Required<Messages[T["type"]]>>}
*/
async function exchangeMessage(message) {
return new Promise((resolve, reject) => { /* … */ });
}
const message = await exchangeMessage({ type: 'a' });
message.optionalNumber; // OK and not optional!