Code working in TypeScript 3.3 broken in 3.5+, why?

Viewed 66

I've just migrated my codebase from TypeScript 3.3 to 3.6 and found that the following code does not compile anymore:

declare const enum Api_Request_Type {
    submit_chat_message = 1,
    pull_chat_messages = 2,
    battle_cheat = 50,
    get_collection_page = 12,

}

type Api_Request = {
    type: Api_Request_Type.submit_chat_message

    request: {
        message: string
    }

    response: {
        messages: string[]
    }
} | {
    type: Api_Request_Type.pull_chat_messages

    request: {}

    response: {
        messages: string[]
    }
} | {
    type: Api_Request_Type.battle_cheat

    request: {
        cheat: string
        selected_unit_id: number
    }

    response: {
    }
} | {
    type: Api_Request_Type.get_collection_page

    request: {
        page: number
    }

    response: object[]
}

type Find_By_Type<Union, Type> = Union extends { type: Type } ? Union : never;
type Find_Request<T> = Find_By_Type<Api_Request, T>["request"]
type Find_Response<T> = Find_By_Type<Api_Request, T>["response"]


function api_request_callback<T extends Api_Request_Type>(type: T, data: Find_Request<T>, callback: (res: Find_Response<T>) => void): void {
    fetch("api" + type, {
        method: "POST",
        body: JSON.stringify(data),
    }).then(data => data.json()).then(data => {
        callback(data as Find_Response<T>);
    });
}

async function api_request<T extends Api_Request_Type>(type: T, data: Find_Request<T>): Promise<Find_Response<T>> {
    return new Promise<Find_Response<T>>((resolve) => {
        api_request_callback(type, data, (abc) => {});
    });
}

And the error for it:

Argument of type '(abc: (Find_By_Type<{ type: Api_Request_Type.submit_chat_message; request: { message: string; }; response: { messages: string[]; }; }, T> | Find_By_Type<{ type: Api_Request_Type.pull_chat_messages; request: {}; response: { messages: string[]; }; }, T> | Find_By_Type<...> | Find_By_Type<...>)["response"]) => void' is not assignable to parameter of type '(res: (Find_By_Type<{ type: Api_Request_Type.submit_chat_message; request: { message: string; }; response: { messages: string[]; }; }, T> | Find_By_Type<{ type: Api_Request_Type.pull_chat_messages; request: {}; response: { messages: string[]; }; }, T> | Find_By_Type<...> | Find_By_Type<...>)["response"]) => void'.
  Types of parameters 'abc' and 'res' are incompatible.
    Type 'Find_By_Type<{ type: Api_Request_Type.submit_chat_message; request: { message: string; }; response: { messages: string[]; }; }, T>["response"] | Find_By_Type<{ type: Api_Request_Type.pull_chat_messages; request: {}; response: { messages: string[]; }; }, T>["response"] | Find_By_Type<...>["response"] | Find_By_Type<....' is not assignable to type 'Find_By_Type<{ type: Api_Request_Type.submit_chat_message; request: { message: string; }; response: { messages: string[]; }; }, T>["response"] & Find_By_Type<{ type: Api_Request_Type.pull_chat_messages; request: {}; response: { messages: string[]; }; }, T>["response"] & Find_By_Type<...>["response"] & Find_By_Type<....'.
      Type 'Find_By_Type<{ type: Api_Request_Type.submit_chat_message; request: { message: string; }; response: { messages: string[]; }; }, T>["response"]' is not assignable to type 'Find_By_Type<{ type: Api_Request_Type.submit_chat_message; request: { message: string; }; response: { messages: string[]; }; }, T>["response"] & Find_By_Type<{ type: Api_Request_Type.pull_chat_messages; request: {}; response: { messages: string[]; }; }, T>["response"] & Find_By_Type<...>["response"] & Find_By_Type<....'.
        Type '{ messages: string[]; }' is not assignable to type 'Find_By_Type<{ type: Api_Request_Type.submit_chat_message; request: { message: string; }; response: { messages: string[]; }; }, T>["response"] & Find_By_Type<{ type: Api_Request_Type.pull_chat_messages; request: {}; response: { messages: string[]; }; }, T>["response"] & Find_By_Type<...>["response"] & Find_By_Type<....'.
          Type '{ messages: string[]; }' is not assignable to type 'Find_By_Type<{ type: Api_Request_Type.get_collection_page; request: { page: number; }; response: object[]; }, T>["response"]'.
            Type '{ messages: string[]; }' is not assignable to type 'object[]'.
              Type 'Find_By_Type<{ type: Api_Request_Type.submit_chat_message; request: { message: string; }; response: { messages: string[]; }; }, T>["response"]' is not assignable to type 'Find_By_Type<{ type: Api_Request_Type.get_collection_page; request: { page: number; }; response: object[]; }, T>["response"]'.
                Type 'Find_By_Type<{ type: Api_Request_Type.submit_chat_message; request: { message: string; }; response: { messages: string[]; }; }, T>' is not assignable to type 'Find_By_Type<{ type: Api_Request_Type.get_collection_page; request: { page: number; }; response: object[]; }, T>'.
                  Type 'Find_By_Type<{ type: Api_Request_Type.submit_chat_message; request: { message: string; }; response: { messages: string[]; }; }, T>["response"]' is not assignable to type 'object[]'.
                    Type '{ messages: string[]; }' is missing the following properties from type 'object[]': length, pop, push, concat, and 25 more.

The error is rather cryptic due to it containing all the type information, but basically it's saying that there is a type mismatch between the abc in (abc) => {} and the res: FindResponse<T> in the api_request_callback signature. This code used to compile in 3.3 and makes sense to me why it would.

I'm sure the error is legit but I just don't understand why and how would I go about fixing it.

2 Answers

Ugh, this is probably another victim of microsoft/TypeScript#30769, a known breaking change in TS3.5. The change enforces the idea that it's only safe to write the intersection of property types to the union of the properties. For example, if I have a variable v of type {x: A, y: B} and a value k of type "x" | "y", if I read v[k], I get something of type A | B, but it's not safe to write a value of type A | B to v[k]. It's only safe to write a value of type A & B. That means the lookup type {x: A, y: B}["x" | "y"] is A | B when you read it, and A & B when you write it. Note that "read" also means "output from a function", and "write" also means "input into a function".

This change has had many good effects, but also a few nasty ones. One of which is that the statement v[k] = v[k] suddenly becomes an error if k is of a union type. That's actually pretty catastrophic if enforced everywhere, so one loophole they left open is if both v and k are generic types, then the compiler thinks v[k] is the same type on both read and write.

So any workaround I can find here will either involve type assertions to force the compiler to bend to your will, or making your types generic enough to exploit the loophole.


If I look into issues filed about this, I see microsoft/TypeScript#31833, entitled "3.5 Breaks function assignments that use a complex Discriminated Union Type". That looks like exactly what's happening here. It's listed as a bug, which is good, but I don't know if it will be fixed anytime soon.


Until then, let's try the workarounds. First, assert our way out:

async function api_request<T extends Api_Request_Type>(
    type: T,
    data: Find_Request<T>
): Promise<Find_Response<T>> {
    return new Promise<Find_Response<T>>((resolve) => {
        api_request_callback(type, data, ((abc: Find_Response<T>) => { }) as any);
    });
}

That works, but it uses the yucky any type. There are probably some slightly less unsafe assertions, but they are undoubtedly ugly.


The second is to make things more generic and exploit the loophole. You're using the concrete Api_Request type and trying to index into its "request" and "response" properties. Let's take everything that uses Api_Request and give it a generic parameter A that both extends and defaults to Api_Request:

type Find_By_Type<Union, Type> = Union extends { type: Type } ? Union : never;
type Find_Request<T, A extends Api_Request = Api_Request> = Find_By_Type<A, T>["request"]
type Find_Response<T, A extends Api_Request = Api_Request> = Find_By_Type<A, T>["response"]

function api_request_callback<T extends Api_Request_Type, A extends Api_Request = Api_Request>(
    type: T,
    data: Find_Request<T, A>,
    callback: (res: Find_Response<T, A>) => void
): void {
    fetch("api" + type, {
        method: "POST",
        body: JSON.stringify(data),
    }).then(data => data.json()).then(data => {
        callback(data as Find_Response<T, A>);
    });
}

async function api_request<T extends Api_Request_Type, A extends Api_Request = Api_Request>(
    type: T,
    data: Find_Request<T, A>
): Promise<Find_Response<T, A>> {
    return new Promise<Find_Response<T, A>>((resolve) => {
        api_request_callback(type, data, ((abc) => { }));
    });
}

Now the compiler is happy. Of course when you actually use these types and functions, A will generally be inferred/defaulted to the concrete Api_Request type, which is exactly the same as what you were already doing. But now you're doing it in a more roundabout way. Hooray?


I'd probably just go with the assertion if I were you, since it changes your code very little. But it's up to you.

Okay, hope that helps; good luck!

Link to code

Hope this helps. let me know if you have any questions

export type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never
export type UnionMembersWith<T, K extends keyof UnionToIntersection<T>, VALUE> = Exclude<T, Exclude<T, Partial<Record<K, VALUE>>>>;


declare const enum Api_Request_Type {
    submit_chat_message = 1,
    pull_chat_messages = 2,
    battle_cheat = 50,
    get_collection_page = 12,

}

type Api_Request = {
    type: Api_Request_Type.submit_chat_message

    request: {
        message: string
    }

    response: {
        messages: string[]
    }
} | {
    type: Api_Request_Type.pull_chat_messages

    request: {}

    response: {
        messages: string[]
    }
} | {
    type: Api_Request_Type.battle_cheat

    request: {
        cheat: string
        selected_unit_id: number
    }

    response: {
    }
} | {
    type: Api_Request_Type.get_collection_page

    request: {
        page: number
    }

    response: object[]
}

type Find_By_Type<Union, Type> = Union extends { type: Type } ? Union : never;

type Test = Find_By_Type<Api_Request_Type.battle_cheat, { type: Api_Request_Type.battle_cheat }>

type Find_Request<T> = Find_By_Type<Api_Request, T>["request"]
type Find_Response<T> = UnionMembersWith<Api_Request, "type", {type: T}>["response"]


function api_request_callback<T extends Api_Request_Type>(type: T, data: Find_Request<T>, callback: (res: Find_Response<T>) => void): void {
    fetch("api" + type, {
        method: "POST",
        body: JSON.stringify(data),
    }).then(data => data.json()).then(data => {
        callback(data as Find_Response<T>);
    });
}

type test = Api_Request["type"]

async function api_request<T extends Api_Request["type"]>(
    type: T,
    data: Find_Request<T>
): Promise<Find_Response<T>> {
    return new Promise<Find_Response<T>>((resolve) => {
        api_request_callback(type, data, (abc) => {
            return 5 as any;
        });
    });
}


api_request(Api_Request_Type.battle_cheat, {cheat: "", selected_unit_id: 5})
Related