microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
100.19k stars 12.38k forks source link

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

Open dartess opened 3 weeks ago

dartess commented 3 weeks ago

πŸ”Ž 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 commented 3 weeks ago

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; }
  },
});