Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Why do I get type errors when resolving this Promise inside a generic method?

Tags:

typescript

I am trying to add some types to this WebSocket protocol:

type Action =
    | {
        action: "change-or-create-state";
        response: string;
    }
    | {
        action: "get-state";
        response: string | null;
    };

/**
 * map an action to its response
 */
type ActionResponse<T extends Action["action"]> = Extract<
    Action,
    { action: T }
>["response"];

export async function doAction<
    TAction extends Action["action"]
>(action: TAction): Promise<ActionResponse<TAction>> { /* ... */ }

I'm having trouble with the types with actually resolving that promise, though:

// in reality received over websocket
const someDataFromAPI: string = '"hello world"'

export async function doAction<
    TAction extends Action["action"]
>(action: TAction): Promise<ActionResponse<TAction>> {
    return new Promise<ActionResponse<TAction>>((resolve) => {
        // I know I could just keep this as 'any' -- the real code does validation
        // which narrows the type.
        const jsonWithSpecificType: ActionResponse<TAction> = JSON.parse(someDataFromAPI);

        /** ⚠️
        Argument of type 'ActionResponse<TAction>' is not assignable to parameter of type 'ActionResponse<TAction> | PromiseLike<ActionResponse<TAction>>'.
        Type 'Extract<{ action: "get-state"; response: string | null; }, { action: TAction; }>["response"]' is not assignable to type 'ActionResponse<TAction> | PromiseLike<ActionResponse<TAction>>'.
        Type 'string | null' is not assignable to type 'ActionResponse<TAction> | PromiseLike<ActionResponse<TAction>>'.
        Type 'null' is not assignable to type 'ActionResponse<TAction> | PromiseLike<ActionResponse<TAction>>'.(2345)
        */
        resolve(jsonWithSpecificType)
    })
}

Playground link here. What am I missing? Obviously any works fine here, but I'm just curious what the issue is. My guess is that its something like it can't guarantee that TAction is the same type inside the arrow function as it was in the outer function?

like image 609
MHebes Avatar asked Oct 20 '25 13:10

MHebes


2 Answers

The closest I can find to a canonical answer to this question is microsoft/TypeScript#47233, which is similar to the problem here and is classified as a bug. That could mean this is a bug, but it could also just be a design limitation. If I find the time I'll open a new issue to get an authoritative answer.

I strongly suspect that the issue here is that the types ActionResponse<TAction> and ActionResponse<TAction> | PromiseLike<ActionResponse<TAction>> are both generic, and thus the compiler cannot evaluate them fully. Since ActionResponse<T> involves some type manipulation like union types, conditional types and indexed accesses) then ActionResponse<TAction> and ActionResponse<TAction> | PromiseLike<ActionResponse<TAction>> are fairly complicated and probably left unevaluated by the compiler. So they are somewhat "opaque".

A human being can see by inspection that the former must be assignable to the latter, by the rule "X is always assignable to X | Y" and pattern-matching ActionResponse<T> for X and PromiseLike<ActionResponse<TAction>> for Y. But the compiler is not a human being, and if it gives up on evaluating the types before considering the union assignability rule, then you get an error.

I think a minimal example for this behavior is something like

type F<K extends "a" | "b"> = (
    ({ x: "a"; y: "a"; } | { x: "b"; y: "b"; }) & { x: K }
)["y"];

function foo<K extends "a" | "b">(fk: F<K>): F<K> | undefined {
    return fk; // error?!
}

where it should be obvious to anyone that F<K> is assignable to F<K> | undefined no matter what F<K> is, but the compiler is unable to see it because of the complicated union discrimination and indexing going on in the definition of F. I use an intersection instead of Extract here, but it's the same basic operation.

For now this answer is just my best educated guess about what's happening. If I file a bug and get a definitive answer I'll come back and edit to reflect it.

Playground link to code

like image 131
jcalz Avatar answered Oct 22 '25 04:10

jcalz


I'm not 100% sure why but this seems to fix it for now -

export async function doAction<
    TAction extends Action["action"],
    R = ActionResponse<TAction> // <--
>(action: TAction): Promise<R> {
    return new Promise((resolve) => {
        const jsonWithSpecificType: R = JSON.parse(someDataFromAPI)  
        resolve(jsonWithSpecificType)
    })
}
const x = doAction("change-or-create-state") // : Promise<string>
const y = doAction("get-state") // : Promise<string | null>
const z = doAction("foo") // : "foo" is not assignable to parameter of type "change-or-create-state" | "get-state"
like image 39
Mulan Avatar answered Oct 22 '25 04:10

Mulan



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!