From 0d9cf3ac2dff9b55af1ef96dfa2282240c9d4af8 Mon Sep 17 00:00:00 2001
From: Lukas Kalbertodt <lukas.kalbertodt@gmail.com>
Date: Thu, 13 Feb 2025 16:48:03 +0100
Subject: [PATCH 1/2] Improve `match` and remove its `fallback` overload

Unfortunately, `match` had a problem where sometimes it was unsound.
This was not really the fault of `match` as TS has questionable
semantics regarding `Record<string, _>`. The following worked before:

    const foo: number = match("bla", {
        "a": () => 3,
    });

So: no fallback, an incomplete object and still, it returns `number`.
This can of course fail at runtime. The problem is that `Record<X, _>`
works differently for `string` and for unions of strings.

The new version solves this by returning `Out | null` if not all cases
are covered, meaning that for `string` it always returns `Out | null`.

I also removed the `fallback` overload as the same can easily be
achieved via `?? fallback`.

I also added a rudimentary compile-test to make sure the problematic
cases actually cause compile errors.
---
 src/util.tsx | 73 ++++++++++++++++++++++++++++++++--------------------
 1 file changed, 45 insertions(+), 28 deletions(-)

diff --git a/src/util.tsx b/src/util.tsx
index d53c974..f441207 100644
--- a/src/util.tsx
+++ b/src/util.tsx
@@ -2,49 +2,66 @@ import { MutableRefObject, useEffect } from "react";
 import { bug } from "./err";
 
 /**
- * A switch-case-like expression with exhaustiveness check (or fallback value).
- * A bit like Rust's `match`, but worse.
+ * A switch-case-like expression with exhaustiveness check. A bit like Rust's
+ * `match`, but worse.
  *
- * If the `fallback` is not given, the given match arms need to be exhaustive.
- * This helps a lot with maintanence as adding a new variant to a union type
- * will throw compile errors in all places that likely need adjustment. You can
- * also pass a fallback (default) value as third parameter, disabling the
- * exhaustiveness check.
+ * If the given match arms are exhaustive, `Out` is returned. If they are not,
+ * `Out | null` is returned, which you need to deal with explicitly. This helps
+ * a lot with maintanence as adding a new variant to a union type will throw
+ * compile errors in all places that likely need adjustment.
  *
  * ```
  * type Animal = "dog" | "cat" | "fox";
  *
  * const animal = "fox" as Animal;
- * const awesomeness = match(animal, {
+ * const awesomeness: number = match(animal, {
  *     "dog": () => 7,
  *     "cat": () => 6,
  *     "fox": () => 100,
  * });
+ *
+ * const maybe: number | null = match(animal, {
+ *     "fox": () => 100,
+ * });
  * ```
  */
-export function match<T extends string | number, Out>(
-  value: T,
-  arms: Record<T, () => Out>,
-): Out;
-export function match<T extends string | number, Out>(
+export function match<T extends string | number, Arms extends T, Out>(
   value: T,
-  arms: Partial<Record<T, () => Out>>,
-  fallback: () => Out,
-): Out;
-export function match<T extends string | number, Out>(
-  value: T,
-  arms: Partial<Record<T, () => Out>>,
-  fallback?: () => Out,
-): Out {
-  return fallback === undefined
-    // Unfortunately, we haven't found a way to make the TS typesystem to
-    // understand that in the case of `fallback === undefined`, `arms` is
-    // not a partial map. But it is, as you can see from the two callable
-    // signatures above.
-    ? arms[value]!()
-    : (arms[value] as (() => Out) | undefined ?? fallback)();
+  arms: Record<Arms, () => Out>,
+): Exclude<T, Arms> extends never ? Out : (Out | null) {
+  return value in arms
+    ? (arms as Record<T, () => Out>)[value]()
+    // This cast is unfortunately necessary
+    : null as Exclude<T, Arms> extends never ? Out : (Out | null);
 }
 
+// Some tests for `match`
+(() => {
+  type Animal = "cat" | "dog" | "fox";
+  type SmallNumber = 1 | 2 | 3;
+
+  // No errors
+  const _a: number = match("cat" as Animal, { cat: () => 1, dog: () => 2, fox: () => 3 });
+  const _b: number | null = match("cat" as Animal, { cat: () => 1, dog: () => 2 });
+  const _c: number | null = match("foo" as string, { foo: () => 1, bar: () => 2 });
+  const _d: number | null = match("foo" as string, { bar: () => 2 });
+  const _e: string = match(1 as SmallNumber, { 1: () => "a", 2: () => "b", 3: () => "c" });
+  const _f: string | null = match(1 as SmallNumber, { 1: () => "a", 2: () => "b" });
+  const _g: string | null = match(1 as number, { 1: () => "a", 2: () => "b" });
+
+  // Should cause compile errors
+  // @ts-expect-error: is nullable
+  const _z: number = match("cat" as Animal, { cat: () => 1, dog: () => 2 });
+  // @ts-expect-error: is nullable
+  const _y: number = match("foo" as string, { foo: () => 1, bar: () => 2 });
+  // @ts-expect-error: an arm is not part of the tag type
+  const _x: number = match("cat" as Animal, { cat: () => 1, red: () => 2 });
+  // @ts-expect-error: is nullable
+  const _w: string = match(1 as SmallNumber, { 1: () => "a", 2: () => "b" });
+  // @ts-expect-error: is nullable
+  const _v: string = match(1 as number, { 1: () => "a", 2: () => "b" });
+})();
+
 
 /** CSS Media query for screens with widths ≤ `w` */
 export const screenWidthAtMost = (w: number) => `@media (max-width: ${w}px)`;

From eb327b6ef61f9cccc53268919119119ad8402338 Mon Sep 17 00:00:00 2001
From: Lukas Kalbertodt <lukas.kalbertodt@gmail.com>
Date: Thu, 13 Feb 2025 18:04:16 +0100
Subject: [PATCH 2/2] Add function `matchTag`

---
 src/util.tsx | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 60 insertions(+)

diff --git a/src/util.tsx b/src/util.tsx
index f441207..be21eef 100644
--- a/src/util.tsx
+++ b/src/util.tsx
@@ -62,6 +62,66 @@ export function match<T extends string | number, Arms extends T, Out>(
   const _v: string = match(1 as number, { 1: () => "a", 2: () => "b" });
 })();
 
+/**
+* Similar to `match` for when you need to narrow a type depending on a tag.
+*
+* In the following example, you can see that we can access `age` and `name`,
+* as if we used `if`, for example. Unlike `match`, this fails to compile if
+* the given match arms do not cover all possibilities.
+*
+* ```
+* type Foo = { kind: "foo", age: number }
+*   | { kind: "bar", name: string };
+*
+* const obj = { kind: "foo", age: 13 } as Foo;
+* const out = matchTag(obj, "kind", {
+*     foo: o => o.age,
+*     bar: o => o.name.length,
+* });
+* ```
+*/
+export function matchTag<
+  T extends string | number,
+  Tag extends string,
+  Obj extends Record<Tag, T>,
+  Out,
+>(
+  obj: Obj,
+  tag: Tag,
+  arms: { [Key in T]: (value: Extract<Obj, Record<Tag, Key>>) => Out },
+): Out {
+  // Unfortunately, I haven't figured out how to make TypeScript understand that
+  // this is safe.
+  // eslint-disable-next-line
+  return arms[obj[tag]](obj as any);
+}
+
+// Some tests for `matchTag`
+(() => {
+  type Foo = { kind: "foo", age: number }
+    | { kind: "bar", name: string };
+
+  const obj = { kind: "foo", age: 13 } as Foo;
+  const _n: number = matchTag(obj, "kind", {
+    foo: o => o.age,
+    bar: o => o.name.length,
+  });
+
+
+  // Should cause compile errors
+  // @ts-expect-error: missing arms
+  // eslint-disable-next-line
+  matchTag(obj, "kind", { foo: o => o.age });
+  // @ts-expect-error: wrong tag
+  // eslint-disable-next-line
+  matchTag(obj, "blub", { foo: o => o.age, bar: o => o.name.length });
+
+  type Stringy = { kind: string, name: string };
+  const s = { kind: "foo", name: "Peter" } as Stringy;
+  // @ts-expect-error: incomplete arms for string tag
+  matchTag(s, "kind", { foo: () => true });
+})();
+
 
 /** CSS Media query for screens with widths ≤ `w` */
 export const screenWidthAtMost = (w: number) => `@media (max-width: ${w}px)`;
