gvergnaud / ts-pattern

🎨 The exhaustive Pattern Matching library for TypeScript, with smart type inference.
MIT License
11.91k stars 125 forks source link

Exhaustiveness check fails for tagged union with a partial discriminant. #278

Open jb-asi opened 3 weeks ago

jb-asi commented 3 weeks ago

Describe the bug Do we see any issue with the first match below, or no? Another reported issue followed up with .otherwise; but - in my case - we use .exhaustive and end up throwing this error at runtime:

Error: Pattern matching error: no pattern matches value {}

Curiously, though, there is no compile-time type error as you'd usually expect to be coming from .exhaustive.

Thinking this may be caused by using a tagged union type with a partial tag (meaning one of the unioned types has an optional tagging property). The match..with..exhaustive expression seems to be fine with { type: undefined } but then throws at runtime. Only when swapping to { type: P.optional(undefined) } does the runtime error subside; but the main issue is that .exhaustive does not seem to be checking for this edge case.

import { match, P } from 'ts-pattern'

// a tagged union where `type` serves as the tag but is optional for the "default" type.
type One = {
  // default
  type?: 'one'; 
};
type Two = {
  type: 'two';
};
type Thing = One | Two;

const thing: Thing = {};

function iThrow(thing: Thing): Thing['type'] {
  return match(thing)
    .with({ type: 'one' }, () => 'one' as const)
    .with({ type: 'two' }, () => 'two' as const)
    // will throw at runtime without a compile-time error
    .with({ type: undefined }, () => undefined) 
    .exhaustive();
}

function imOk(thing: Thing): Thing['type'] {
  return match(thing)
    .with({ type: 'one' }, () => 'one' as const)
    .with({ type: 'two' }, () => 'two' as const)
    .with({ type: P.optional(undefined) }, () => undefined) 
    .exhaustive();
}

console.log(iThrow(thing));
console.log(imOk(thing));

As another data point, if we change the types like so:

type One = {
  // default
  type?: undefined; 
};
type Two = {
  type: undefined;
};

There is an .exhaustive error in that case.

Really appreciate this library. If this seems simple enough for a public contributor to assist with in some capacity, let me know.

Versions

jb-asi commented 3 weeks ago

To give a little more context why this may have to do with the union, this simple example does fail the .exhaustive check with a NonExhaustiveError-type error.

type A = {
  key?: undefined;
};
const a: A = {};
match(a)
  .with({ key: undefined }, () => void undefined)
  .exhaustive();