Home > database >  TypeScript JavaScript JSDoc @template type union confusion
TypeScript JavaScript JSDoc @template type union confusion

Time:07-11

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
}

Playground


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;

Playground

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!

Playground

  • Related