Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

The order of values ​​in a union affects the correct type inference #59729

Open
dartess opened this issue Aug 23, 2024 · 2 comments
Open
Labels
Help Wanted You can do this Possible Improvement The current behavior isn't wrong, but it's possible to see that it might be better in some cases Union Order Dependence TS behavior is not supposed to depend on union order, but sometimes does
Milestone

Comments

@dartess
Copy link

dartess commented Aug 23, 2024

🔎 Search Terms

union order type infer

🕗 Version & Regression Information

This is the behavior in every version I tried, and I reviewed the FAQ for entries about union / order

⏯ Playground Link

https://www.typescriptlang.org/play/?ts=5.5.4#code/C4TwDgpgBAshDO8CGBzaBeKBvKB7MwAlrgHbwD8AXFAEoQDGuATgCYA88wThJKANFACuJANYlcAdxIA+KAF8A3ACgloSFAASSEiwA2EJmwAqszAAoAtgmRpqRgJRR0sgG65CLZUpYNdSJtAAZsL0RKRQ8IIARvD03FEQxlAQAB7AEDrwsNaoENJmSlBFUGBIILq4SCyUhcVFAD7YUFaIuXbyUABkTQAW2noG1Fo6+oYm8rV1jTgtNhDUZo7OUEYd3Th9I4Oa-aPGsnK19tRuHl6RMXGECWZYtbNtUItOsrd4BMRk1DP+IhDA1AA5BZfv9AR05PY+LVNgMmAscPgwmR5EtZHc6pjigB6bFQAB65FqcmhkOUQA

💻 Code

type Message = { options?: Record<string, unknown> };

type Handler<T> = (message: T) => void;

declare function subscribe<T extends Message>(
    payload:
      | { message: T } & { handler: Handler<T> }
      | { message: () => T } & { handler: Handler<T> }
  ): void;

subscribe({
  message: () => ({ options: { market: 'market' } }),
  handler: ({ options }) => {
                  // ^? Record<string, unknown>
  },
});

🙁 Actual behavior

options type is Record<string, unknown>

🙂 Expected behavior

options type should be inferred as { market: string }

Additional information about the issue

if you reverse the order of values in the union:

declare function subscribe<T extends Message>(
    payload:
      | { message: () => T } & { handler: Handler<T> }
      | { message: T } & { handler: Handler<T> }
  ): void;

then the type will be inferred correctly

@Andarist
Copy link
Contributor

Andarist commented Aug 23, 2024

It depends on how TypeScript breaks down your union. The inference targets are not at the same level so TypeScript can't discriminate this by how the target is contained in the union today.

It works OK when T is directly contained in a union like this:

payload: { message: T | (() => T) } & { handler: Handler<T> }

With your variant is actually always finds 2 inference candidates:

type Candidate1 = () => { options: { market: string; }; } 
type Candidate2 = { options: { market: string; }; } 

But then which candidate gets selected always depends on the order (candidates are reduced from left to right). So, at times, Candidate1 gets selected. Then it fails constraint check and the final inferred type fallbacks to the constraint itself. That's what you are observing. It just happens that... you never see it because it lucked out and your arguments are assignable to the signature instantiated with that constraint. You might find this comment interesting if you are curious about some of the current implementation details.

A smile solution to fix this could involve filtering candidates themselves by constraint applicability (right now only selected inferred type gets matched against it). I assume this wasn't tried for performance reasons but I could be wrong.

Fixing this earlier - when candidates are collected might be difficult.

It's possible to work around this on your side as follows (TS playground):

declare const fnSymbol: unique symbol;

interface Function {
  [fnSymbol]?: true;
}

type NotFunction<T> = T & {
  [fnSymbol]?: never;
};

type Message = { options?: Record<string, unknown> };

type Handler<T> = (message: T) => void;

declare function subscribe<T extends Message>(
  payload:
    | ({ message: NotFunction<T> } & { handler: Handler<T> })
    | ({ message: () => T } & { handler: Handler<T> }),
): void;

subscribe({
  message: () => ({ options: { market: "market" } }),
  handler: ({ options }) => {
    //        ^? (parameter) options: { market: string; }
  },
});

@RyanCavanaugh RyanCavanaugh added Help Wanted You can do this Possible Improvement The current behavior isn't wrong, but it's possible to see that it might be better in some cases labels Aug 26, 2024
@RyanCavanaugh RyanCavanaugh added this to the Backlog milestone Aug 26, 2024
@olegdunkan
Copy link

@Andarist It is also works, but I can guess that checker selects second constituent first because don't want to dig in SkipCheckForWhile<T> for while, but more likely I am wrong.

type Message = { options?: Record<string, unknown> };

type Handler<T> = (message: T) => void;

type SkipCheckForWhile<T> = T & {}

declare function subscribe<T extends Message>(
  payload:
    | ({ message: SkipCheckForWhile<T> } & { handler: Handler<T> })
    | ({ message: () => T } & { handler: Handler<T> }),
): void;


subscribe({
  message: () => ({ options: { market: "market" } }),
  handler: ({ options }) => {
    //        (parameter) options: { market: string; }
  },
});

subscribe({
  message: { options: { market: "market" } },
  handler: ({ options }) => {
    //        (parameter) options: { market: string; }
  },
});

@RyanCavanaugh RyanCavanaugh added the Union Order Dependence TS behavior is not supposed to depend on union order, but sometimes does label Sep 26, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Help Wanted You can do this Possible Improvement The current behavior isn't wrong, but it's possible to see that it might be better in some cases Union Order Dependence TS behavior is not supposed to depend on union order, but sometimes does
Projects
None yet
Development

No branches or pull requests

4 participants