match icon indicating copy to clipboard operation
match copied to clipboard

Behavior on patterns whose members are not statically-known at compile time

Open hasundue opened this issue 2 months ago • 2 comments

Suppose we want to provide a function that extracts values from an object by keys with a type guard, for example:

import { assertEquals, assertThrows } from "jsr:@std/assert";
import { assertType, Has } from "jsr:@std/testing/types";

Deno.test("extract", () => {
  // Users can define their own extract function
  const extractDate = (value: unknown) =>
    extract(
      value,
      ["day", "month"],
      (value: unknown): value is number => typeof value === "number",
    );

  const matched = extractDate({ day: 21, month: 4, location: "Tokyo" });
  assertType<Has<typeof matched, number[]>>(true);
  assertEquals(matched, [21, 4]);

  assertThrows(() => extractDate({ day: 21, location: "Osaka" }));
});

I find the library makes me implement this kind of function quite elegantly:

import { match, placeholder as _, } from "jsr:@core/[email protected]";
import { associateWith } from "jsr:@std/collections/associate-with";

function extract<V extends unknown>(
  from: unknown, // expected to extend Record<string, V>
  by: string[],
  guard: (value: unknown) => value is V, // type guard for each entry
): V[] {
  const pattern = associateWith(by, (it) => _(it, guard));
  // => Record<string, RegularPlaceholder<string, (value: unknown) => value is V>>
  const result = match(pattern, from);
  // => undefined
  if (!result) {
    throw new TypeError(`Could not extract expected values from ${from}.`);
  }
  return by.map((key) => result[key]));
  // => never[]
}

This code runs as expected and passes the test. But the problem here is that the type of result is inferred as undefined, which does not seem consistent with the actual behavior.

We can improve this a bit by binding the type of by to a type parameter:

import {
  match,
  placeholder as _,
  RegularPlaceholder,
} from "jsr:@core/[email protected]";
import { associateWith } from "jsr:@std/collections/associate-with";

function extract<K extends string, V extends unknown>(
  from: unknown, // expected to extend Record<K, V>
  by: K[],
  guard: (value: unknown) => value is V, // type guard for each entry
): V[] {
  const pattern = associateWith(
    by,
    (it) => _(it, guard),
  ) as { [L in K]: RegularPlaceholder<L, (value: unknown) => value is V> };

  const result = match(pattern, from);
  // => Match<{ [L in K]: RegularPlaceholder<L, (value: unknown) => value is V>; }> | undefined

  if (result === undefined) {
    throw new TypeError(`Could not extract expected values from ${from}.`);
  }
  return by.map((key) => result[key]);
  // => Type 'Match<{ [L in K]: RegularPlaceholder<L, (value: unknown) => value is V>; }>[K][]' is not assignable to type 'V[]'.
}

This code also passes the test. Now the type of result is inferred to be non-nullable, but still does not allow further operations on it.

I would like to see the type of result more "expected", like Record<string, V> | undefined in the first implementation, and Record<K, V> | undefined in the second one.

I'm totally not sure if this is a justified request in terms of the original concept of the library, but I don't beleive it a bad idea to share what I experimented with you here 😃

hasundue avatar Apr 21 '24 23:04 hasundue