Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 104 additions & 27 deletions src/util.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,49 +2,126 @@ 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>(
value: T,
arms: Partial<Record<T, () => Out>>,
fallback: () => 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,
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" });
})();

/**
* 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 {
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)();
// 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)`;
Expand Down