Repository: gvergnaud/ts-pattern

Issue:
Exhaustiveness check fails for tagged union with a partial discriminant.
**Describe the bug**
Do we see any issue with the first match below, or no? [Another reported issue](https://github.com/gvergnaud/ts-pattern/issues/109#issue-1359407163) 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.

``` ts
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:
``` ts
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**
- TypeScript version: 5.4.5
- ts-pattern version: 5.1.1
- environment: Chrome 128.0.6613.114


Discussion:
@jb-asi: 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.
``` tsx
type A = {
  key?: undefined;
};
const a: A = {};
match(a)
  .with({ key: undefined }, () => void undefined)
  .exhaustive();
```@jb-asi: @gvergnaud, would you say enough detail was provided here in order to discuss this issue? No rush; but I notice a newer issue has your reply and a little time has passed. Please do keep me honest if I can give more detail.@gvergnaud: Sorry I didn't have the time to look into this and investigate. It's most likely not an easy fix, that's why it's taking longer than other issues. Your issue was clear, thanks for providing these code examples!@nicoespeon: Diving into this one a little bit since I just ran across it. Here's the test I think we can use to repro:

```ts
  it('should detect missing property', () => {
    type Input = { type?: 'one' } | { type: 'two' };

    const f = (input: Input) =>
      match(input)
        .with({ type: 'one' }, (x) => {
          return false;
        })
        .with({ type: 'two' }, (x) => {
          return false;
        })
        .with({ type: undefined }, (x) => {
          type t = Expect<Equal<typeof x, { type: undefined }>>;
          return true;
        })
        .exhaustive();

    expect(f({ type: 'one' })).toBe(false);
    expect(f({ type: 'two' })).toBe(false);
    expect(f({})).toBe(true);
  });
```

👆 Won't generate a type error, but the last `expect()` will definitely fail at runtime. But in fact, this one would too:

```ts
  it('should support exhaustive check on optional property', () => {
    type Input = { type?: 'one' } | { type: 'two' };

    const f = (input: Input) =>
      match(input)
        .with({ type: 'one' }, (x) => {
          return false;
        })
        .with({ type: 'two' }, (x) => {
          return false;
        })
        .exhaustive();

    expect(f({ type: 'one' })).toBe(false);
    expect(f({ type: 'two' })).toBe(false);
    expect(f({})).toBe(true);
  });
```

Hope that helps. I'll see if I can give more hints on the potential fix.

## About `P.optional(undefined)`

The `P.optional(undefined)` option works, and this test passes:

```ts
  it('should support optional property', () => {
    type Input = { type?: 'one' } | { type: 'two' };

    const f = (input: Input) =>
      match(input)
        .with({ type: 'one' }, (x) => {
          return false;
        })
        .with({ type: 'two' }, (x) => {
          return false;
        })
        .with({ type: P.optional(undefined) }, (x) => {
          type t = Expect<Equal<typeof x, { type?: undefined }>>;
          return true;
        })
        .exhaustive();

    expect(f({ type: 'one' })).toBe(false);
    expect(f({ type: 'two' })).toBe(false);
    expect(f({})).toBe(true);
  });
```

However, that only works if `P.optional(undefined)` is given last. If we move the check up, then the inferred type is erroring:

```ts
  it('should support intermediate P.optional(undefined)', () => {
    type Input = { type?: 'one', name: string } | { type: 'two' };

    const f = (input: Input) =>
      match(input)
        .with({ type: 'one' }, (x) => {
          return false;
        })
        .with({ type: P.optional(undefined) }, (x) => {
          type t = Expect<Equal<typeof x, { type?: undefined, name: string }>>;
          // ^ typeof x is { type?: undefined; name: string } | { type?: undefined }
          return true;
        })
        .with({ type: 'two' }, (x) => {
          return false;
        })
        .exhaustive();

    expect(f({ type: 'one', name: "John" })).toBe(false);
    expect(f({ type: 'two' })).toBe(false);
    expect(f({ name: "Jane" })).toBe(true);
  });
```

_P.S.: This is the bug I ran into at compile time (which is not as bad as a runtime error, but confusing with larger types 😄)_@gvergnaud: Thank you so much @nicoespeon, this was a very useful breakdown. I _think_ I have a fix ([PR](https://github.com/gvergnaud/ts-pattern/pull/319)). Unit tests are all passing, but I haven't had the time to test it properly on a real codebase. If everything goes well I should be able to release it soon.

> However, that only works if P.optional(undefined) is given last.

This does look like a bug too, the `{ type?: undefined }` is indeed unexpected in this scenario. Would you mind opening a separate PR for it?@nicoespeon: @gvergnaud, it's indeed a different issue, so I'll open a new one for that 👍

Solution patch:
diff --git a/jsr.json b/jsr.json
index 97ae9eb..091b2ab 100644
--- a/jsr.json
+++ b/jsr.json
@@ -1,6 +1,6 @@
 {
   "name": "@gabriel/ts-pattern",
-  "version": "5.6.2",
+  "version": "5.7.1",
   "exports": {
     ".": "./src/index.ts",
     "./types": "./src/index.ts",
diff --git a/package-lock.json b/package-lock.json
index 147d166..e95a952 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "ts-pattern",
-  "version": "5.7.0",
+  "version": "5.7.1",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "ts-pattern",
-      "version": "5.7.0",
+      "version": "5.7.1",
       "license": "MIT",
       "devDependencies": {
         "@types/jest": "^29.5.2",
diff --git a/package.json b/package.json
index d3245a7..42238c5 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "ts-pattern",
-  "version": "5.7.0",
+  "version": "5.7.1",
   "description": " The exhaustive Pattern Matching library for TypeScript.",
   "type": "module",
   "source": "src/index.ts",
diff --git a/src/types/BuildMany.ts b/src/types/BuildMany.ts
index 9719320..2a62bd7 100644
--- a/src/types/BuildMany.ts
+++ b/src/types/BuildMany.ts
@@ -1,4 +1,4 @@
-import { Iterator, UpdateAt, ValueOf } from './helpers';
+import { Iterator, IsOptionalKeysOf, UpdateAt, ValueOf } from './helpers';
 
 // BuildMany :: DataStructure -> Union<[value, path][]> -> Union<DataStructure>
 export type BuildMany<data, xs extends readonly any[]> = xs extends any
@@ -31,10 +31,15 @@ export type SetDeep<data, value, path> = path extends readonly [
     : data extends Map<infer k, infer v>
     ? Map<k, SetDeep<v, value, tail>>
     : head extends keyof data
-    ? {
-        [k in keyof data]-?: k extends head
-          ? SetDeep<data[head], value, tail>
-          : data[k];
-      }
+    ? // if we intentionally set undefined on an optional key, we should keep
+      // the optional modifier, otherwise it will exclude the `undefined` type from
+      // our `value` type.
+      [IsOptionalKeysOf<data, head>, tail, undefined] extends [true, [], value]
+      ? { [k in keyof data]: k extends head ? value : data[k] }
+      : {
+          [k in keyof data]-?: k extends head
+            ? SetDeep<data[head], value, tail>
+            : data[k];
+        }
     : data
   : value;
diff --git a/src/types/InvertPattern.ts b/src/types/InvertPattern.ts
index 966fdba..fdb0e97 100644
--- a/src/types/InvertPattern.ts
+++ b/src/types/InvertPattern.ts
@@ -363,7 +363,9 @@ type InvertPatternForExcludeInternal<p, i, empty = never> =
         >
       : never
     : IsPlainObject<p> extends true
-    ? i extends object
+    ? Equal<{}, p> extends true
+      ? {}
+      : i extends object
       ? [keyof p & keyof i] extends [never]
         ? empty
         : OptionalKeys<p> extends infer optKeys
diff --git a/src/types/helpers.ts b/src/types/helpers.ts
index f62b194..2644986 100644
--- a/src/types/helpers.ts
+++ b/src/types/helpers.ts
@@ -267,3 +267,10 @@ export interface Fn {
 export type Call<fn extends Fn, input> = (fn & {
   input: input;
 })['output'];
+
+export type IsOptionalKeysOf<obj, key extends keyof obj> = {} extends Pick<
+  obj,
+  key
+>
+  ? true
+  : false;



Test patch:
diff --git a/tests/deep-exclude.test.ts b/tests/deep-exclude.test.ts
index 4a25217..c9e9a83 100644
--- a/tests/deep-exclude.test.ts
+++ b/tests/deep-exclude.test.ts
@@ -128,6 +128,21 @@ describe('DeepExclude', () => {
         >
       ];
     });
+
+    it("should exclude the undefined case of optional properties when the pattern's key is also optional", () => {
+      type input = {
+        type: 'a';
+        data?: { type: 'img'; src: string } | { type: 'text'; p: string };
+      };
+      type pattern = {
+        readonly type: 'a';
+        readonly data?: { readonly type: 'img' };
+      };
+      type res1 = DeepExclude<input, pattern>;
+      type test1 = Expect<
+        Equal<res1, { type: 'a'; data: { type: 'text'; p: string } }>
+      >;
+    });
   });
 
   describe('Tuples', () => {
diff --git a/tests/exhaustive-match.test.ts b/tests/exhaustive-match.test.ts
index 554447f..0428a46 100644
--- a/tests/exhaustive-match.test.ts
+++ b/tests/exhaustive-match.test.ts
@@ -990,4 +990,96 @@ describe('exhaustive()', () => {
       .with(P.instanceOf(Date), (value) => 3)
       .exhaustive();
   });
+
+  describe('issue #278: should support exhaustive check on optional property', () => {
+    // https://github.com/gvergnaud/ts-pattern/issues/278
+
+    type Input = { type?: 'one' } | { type: 'two' };
+
+    it("exhaustive should fail when the optional key isn't provided", () => {
+      const f = (input: Input) =>
+        match(input)
+          .with({ type: 'one' }, (x) => {
+            return false;
+          })
+          .with({ type: 'two' }, (x) => {
+            return false;
+          })
+          // @ts-expect-error
+          .exhaustive();
+
+      expect(f({ type: 'one' })).toBe(false);
+      expect(f({ type: 'two' })).toBe(false);
+      expect(() => f({})).toThrow();
+    });
+
+    it('exhaustive should fail when matching on { type: undefined }', () => {
+      // Because { type: undefined } doesn't match when passing an empty object –
+      // the key must be present on the object.
+      const f = (input: Input) =>
+        match(input)
+          .with({ type: 'one' }, (x) => {
+            return false;
+          })
+          .with({ type: 'two' }, (x) => {
+            return false;
+          })
+          .with({ type: undefined }, (x) => {
+            type t = Expect<Equal<typeof x, { type: undefined }>>;
+            return true;
+          })
+          // @ts-expect-error
+          .exhaustive();
+
+      expect(f({ type: 'one' })).toBe(false);
+      expect(f({ type: 'two' })).toBe(false);
+      expect(f({ type: undefined })).toBe(true);
+      expect(() => f({})).toThrow();
+    });
+
+    it('exhaustive should pass when using P.optional(undefined)', () => {
+      const f = (input: Input) =>
+        match(input)
+          .with({ type: 'one' }, (x) => {
+            return false;
+          })
+          .with({ type: 'two' }, (x) => {
+            return false;
+          })
+          .with({ type: P.optional(undefined) }, (x) => {
+            type t = Expect<Equal<typeof x, { type?: undefined }>>;
+            return true;
+          })
+          .exhaustive();
+
+      expect(f({ type: 'one' })).toBe(false);
+      expect(f({ type: 'two' })).toBe(false);
+      expect(f({ type: undefined })).toBe(true);
+      expect(f({})).toBe(true);
+    });
+
+    it('exhaustive should pass when using {} and { type: undefined }', () => {
+      const f = (input: Input) =>
+        match(input)
+          .with({ type: 'one' }, (x) => {
+            return false;
+          })
+          .with({ type: 'two' }, (x) => {
+            return false;
+          })
+          .with({ type: undefined }, (x) => {
+            type t = Expect<Equal<typeof x, { type: undefined }>>;
+            return true;
+          })
+          .with({}, (x) => {
+            return true;
+          })
+          .exhaustive();
+
+      expect(f({ type: 'one' })).toBe(false);
+      expect(f({ type: 'two' })).toBe(false);
+      expect(f({ type: undefined })).toBe(true);
+      expect(f({})).toBe(true);
+    });
+  });
 });
diff --git a/tests/optional-props.test.ts b/tests/optional-props.test.ts
index 690eaa3..9aba74b 100644
--- a/tests/optional-props.test.ts
+++ b/tests/optional-props.test.ts
@@ -101,7 +101,7 @@ describe('optional properties', () => {
         return `Foo: ${value.b}`;
       })
       .with({ a: SomeEnum.Bar }, (value) => `Bar: ${value.b}`)
-      .with({ a: undefined }, (value) => `<undefined>: ${value.b}`)
+      .with({ a: P.optional(undefined) }, (value) => `<undefined>: ${value.b}`)
       .exhaustive();
 
     expect(result).toEqual(`Foo: not important`);