microsoft / microsoft/TypeScript

Unpredictable behavior when trying to infer a generic type from a union function parameter

Open
#59,046 3 comments 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

Bug Domain: check: Type Inference Help Wanted
Dominant language
Go
Stars
111k
Forks
14.4k
Avg merge
1d 19h
Merged PRs (30d)
117

Description

🔎 Search Terms

"parameter generic inference", "union order", "ts2345", "union generic parameter"

🕗 Version & Regression Information
  • This is the behavior in every version I tried, and I reviewed the FAQ for entries about unions and generics though I went through it all in general
⏯ Playground Link

https://www.typescriptlang.org/play/?#code/C4TwDgpgBAygrgYwRAzimwCGw4qgXigCYAGEqAH2JIEYBuAKFEigEkA7AN0wBsBLACYZsuAlAAsZShNrTJROSQDMi8YybhoAWRAAlVGAD27FBAA8AFQA0sKBAAewCOwF54SVOiw48VDt34hb1wAPjEAbwYoKBRglAAuWEZowwBrRJg7R2dXWERkNGEfKAB+KGAAJzhoDKynFzx-XkEi0TKAM15TKESAI0NDHghMdmSoACsUYwAKAEpEgAUKwwBbPlNLEMYAX3UHIwrgKARjWKgKg1OIAAkRgSGKsUwUEHYEKEsrEOmo88uTCDxX7RKg6fQoIwAz55DyFOIhYHSMH-DbhOwVZYVLSeTAAc0BMUqfHYuKg2xsTUCrRQCNmBDCkWifHaUGmAEILhCrgA6NJ0xnRY6nI5oiAYwxYnH4slPADumD4R05kNM3MmM1mY2iwAAFstZVB2BADQBRcUVaZizHYtB4iCa37bX4XHAVdh-LkAtVTdhzHbqAD0AagAANwoJErEKsTSdsQ1AdWLoAgRlBetARiBdTGbL04Ecw+jrVKCVGY2T4wwg1BZXweDwoLx5SA8OmoGBOc5gDZxrgjuxDP2IAUUJhozwQDXFTrQ5JxCGbGtcTqjm3M2wuM0giIUBoWAtDGg+L0hhZNCgAIIEX6gvQo8xoiOE6Mksk2UgkBEgqDIz2oosSjao74pGRKvuSMjiFsDB7tAB5HieEBnpAKAAELXt+v4qg+AGSraIHPuWEFzl+SJ3n+OFPmW4HvmQ0GwVAABqW7gthYhYVcZiPgIoEvrGtGfowzDQGamKsVc7HkdhXG4UBdq8URNgkUJmhQPBKDHqe54AMJiKJEriQC0jMYEhmmOoJwmEcAjYJgV6EJg8qKh62G3C4DzTOE2yNng6maUh54Xg6gohaFYXhRFgrVgAejWEqpHgzxZJACBOAIDCWWcNlYOhDlOUq95ufcYqed5SV+YhyGoKhwWRXV9WhTFNbAO0JQZcKUDZZgul5QqBUUUVHleT5amHhplU6bVDXTZFTUDgazmyvFiV4Psw5pdym1pvmNY6iAJRAA

💻 Code
type SuccessStatus = 200 | 201;
type InvalidStatus = 400 | 401 | 402 | 403 | 404;

type MyResponse<T, S extends SuccessStatus | InvalidStatus> = {
  status: S;
  ok: S extends SuccessStatus ? true : S extends InvalidStatus ? false : boolean;
  json(): Promise<T>;
};

export const responseHandler = async <T,>(
  response:
    | MyResponse<T, SuccessStatus>
    | MyResponse<{ errorMessage: string }, InvalidStatus>
) => {
  if (!response.ok) {
    const { errorMessage } = await response.json();
    throw new Error(errorMessage);
  }
  return response.json();
};

// `{id: string }` here can be anything, but `{ errorMessage: string }` 
// will always be present, just not necessarily with `404`, might be any InvalidStatus
type PossibleTypesA =
  | MyResponse<{ id: string }, 200>
  | MyResponse<{ errorMessage: string }, 404>;

type PossibleTypesB =
  | MyResponse<{ errorMessage: string }, 404>
  | MyResponse<{ id: string }, 200>;

type ValidResponse = MyResponse<{ id: string }, 200>;
type ErrorResponse = MyResponse<{ errorMessage: string }, 404>;
type PossibleTypesC = ErrorResponse | ValidResponse;

const dataA = await responseHandler({} as PossibleTypesA);
                                    // ^ works as expected
const dataB = await responseHandler({} as PossibleTypesB);
                                    // ^ wtf?
const dataC = await responseHandler({} as PossibleTypesC);
                                    // ^ now it works as expected... but why?
🙁 Actual behavior

responseHandler in the snippet above throws a ts2345 error when a union type with a swapped order is passed as an argument. Until now I thought a union type a | b is equivalent to b | a but this doesn't seem to be the case. Curiously, this is no longer an issue if the types that are part of the union are aliased, as can be seen in example C (dataC and PossibleTypesC).

🙂 Expected behavior

dataB should not throw an error.

Additional information about the issue

There is an even crazier example and perhaps it's a better proof that this is a bug.

type SomeType = { id: string };

type PossibleTypesA =
  | MyResponse<{ id: string }, 200>
  | MyResponse<{ errorMessage: string }, 404>;

type PossibleTypesB =
  | MyResponse<{ errorMessage: string }, 404>
  | MyResponse<SomeType, 200>;

const dataA = await responseHandler({} as PossibleTypesA);
                                    // ^ works as expected
const dataB = await responseHandler({} as PossibleTypesB);
                                    // ^ does not work but that's why I'm reporting it

Playground Link

type SomeType = { id: string };

type PossibleTypesA =
  | MyResponse<SomeType, 200>
  | MyResponse<{ errorMessage: string }, 404>;

type PossibleTypesB =
  | MyResponse<{ errorMessage: string }, 404>
  | MyResponse<SomeType, 200>;

const dataA = await responseHandler({} as PossibleTypesA);
                                    // ^ works as expected
const dataB = await responseHandler({} as PossibleTypesB);
                                    // ^ it works now???

Playground Link

This is pure insanity to me as the only thing I changed between these two examples is whether PossibleTypesA uses SomeType or not. It should not in any circumstance affect dataB, so I'm extremely confused what is happening here.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the linked TypeScript Playground reproductions and compare responseHandler calls for PossibleTypesA, PossibleTypesB, and PossibleTypesC. Investigate generic inference for unions whose members are written in different orders or use aliases. Done means equivalent union orderings no longer produce inconsistent ts2345 errors, while the demonstrated examples continue to infer correctly.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
compilers
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Stale
Clarity
Mostly clear
Newbie friendliness
28/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.