Skip to content

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

@hasundue

Description

@hasundue

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/match@0.3.0";
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/match@0.3.0";
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 😃

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions