From a25ac6d1e27729eccac6de6c6c97e989d437db21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Wed, 16 Sep 2026 14:52:30 +0000 Subject: [PATCH] :white_check_mark: Spec primitive union matchers Type-driven tests: every expectTypeOf pairs an assert, covering the header matrix (exhaustive vs partial patterns, strict R vs widened returns, string and numeric keys, _ fallback wiring). Negative cases use Parameters[0] assignability because expect-type's .not.toBeCallableWith collapses to never on these generic Simplify<>-wrapped signatures. Same documented expectTypeOf floating-promise false positive as index.test.ts, so the same file-level disable applies; testing.md known-issue updated to list both files. --- development/testing.md | 3 +- src/primitive.test.ts | 128 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 src/primitive.test.ts diff --git a/development/testing.md b/development/testing.md index d4ef8be..c58bbda 100644 --- a/development/testing.md +++ b/development/testing.md @@ -39,7 +39,8 @@ build step, and the runner relies on the `.ts` import-extension convention (see ## Known issues - The type-aware linter misidentifies `expectTypeOf()` as a floating promise, so - `src/index.test.ts` carries a file-level `oxlint-disable + test files that use it (`src/index.test.ts`, `src/primitive.test.ts`) carry a + file-level `oxlint-disable typescript/no-floating-promises` with an explanatory comment. It is a known false positive, not a rule worth disabling project-wide (see [tooling.md § oxlint-disable directives live next to the code](./tooling.md#oxlint-disable-directives-live-next-to-the-code)). diff --git a/src/primitive.test.ts b/src/primitive.test.ts new file mode 100644 index 0000000..a2be8e9 --- /dev/null +++ b/src/primitive.test.ts @@ -0,0 +1,128 @@ +/* oxlint-disable typescript/no-floating-promises -- expectTypeOf() is a sync type-assertion library that the type-aware linter misidentifies as a promise */ +import { strict as assert } from "node:assert"; +import { test } from "node:test"; + +import { expectTypeOf } from "expect-type"; + +import { + getPrimitiveUnionMatcher, + getPrimitiveUnionMatcherPartial, + getPrimitiveUnionMatcherPartialW, + getPrimitiveUnionMatcherW, +} from "./primitive.ts"; + +// ============================================================================ +// API: getPrimitiveUnionMatcherW — ✔️ Exhaustive / ❌ ReturnsStrict +// ============================================================================ + +test("getPrimitiveUnionMatcherW requires every literal key", () => { + const matcher = getPrimitiveUnionMatcherW<"a" | "b">()({ + a: () => 1 as const, + b: () => "two" as const, + }); + // ❌ ReturnsStrict: mixed handler returns widen to their union. + expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b") => 1 | "two">(); + // A pattern missing a key must not satisfy the parameter type. + const factory = getPrimitiveUnionMatcherW<"a" | "b">(); + expectTypeOf<{ + a: () => number; + }>().not.toMatchTypeOf[0]>(); + assert.equal(matcher("a"), 1); + assert.equal(matcher("b"), "two"); +}); + +test("getPrimitiveUnionMatcherW dispatches on numeric literal keys", () => { + const matcher = getPrimitiveUnionMatcherW<1 | 2>()({ + 1: (n) => n + 1, + 2: (n) => n * 10, + }); + expectTypeOf(matcher).toEqualTypeOf<(shape: 1 | 2) => number>(); + assert.equal(matcher(1), 2); + assert.equal(matcher(2), 20); +}); + +// ============================================================================ +// API: getPrimitiveUnionMatcher — ✔️ Exhaustive / ✔️ ReturnsStrict +// ============================================================================ + +test("getPrimitiveUnionMatcher infers a single return type shared by all handlers", () => { + const matcher = getPrimitiveUnionMatcher<"a" | "b">()({ + a: (): number => 1, + b: (): 1 | 2 => 2, + }); + // ✔️ ReturnsStrict: R is the best common return type, not a widening union. + expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b") => number>(); + assert.equal(matcher("a"), 1); + assert.equal(matcher("b"), 2); +}); + +test("getPrimitiveUnionMatcher handlers receive the matched literal", () => { + const matcher = getPrimitiveUnionMatcher<"on" | "off">()({ + on: (s) => { + expectTypeOf(s).toEqualTypeOf<"on">(); + return `handler ${s}`; + }, + off: (s) => { + expectTypeOf(s).toEqualTypeOf<"off">(); + return `handler ${s}`; + }, + }); + expectTypeOf(matcher).toEqualTypeOf<(shape: "on" | "off") => string>(); + assert.equal(matcher("on"), "handler on"); + assert.equal(matcher("off"), "handler off"); +}); + +// ============================================================================ +// API: getPrimitiveUnionMatcherPartial — ❌ Exhaustive / ✔️ ReturnsStrict +// ============================================================================ + +test("getPrimitiveUnionMatcherPartial routes shapes without a handler to _", () => { + const matcher = getPrimitiveUnionMatcherPartial<"a" | "b" | "c">()({ + a: (): 1 | 2 => 1, + _: (s): 1 | 2 => { + // The fallback sees the whole union, not a single literal. + expectTypeOf(s).toEqualTypeOf<"a" | "b" | "c">(); + return 2; + }, + }); + expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b" | "c") => 1 | 2>(); + // ❌ Exhaustive: gaps are allowed, but only with a `_` fallback. + const factory = getPrimitiveUnionMatcherPartial<"a" | "b">(); + expectTypeOf<{ + a: () => number; + }>().not.toMatchTypeOf[0]>(); + assert.equal(matcher("a"), 1); + assert.equal(matcher("b"), 2); + assert.equal(matcher("c"), 2); +}); + +test("getPrimitiveUnionMatcherPartial also accepts an exhaustive pattern", () => { + const matcher = getPrimitiveUnionMatcherPartial<"a" | "b">()({ + a: () => "A", + b: () => "B", + }); + expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b") => string>(); + assert.equal(matcher("a"), "A"); + assert.equal(matcher("b"), "B"); +}); + +// ============================================================================ +// API: getPrimitiveUnionMatcherPartialW — ❌ Exhaustive / ❌ ReturnsStrict +// ============================================================================ + +test("getPrimitiveUnionMatcherPartialW allows gaps and widens to the union of handler returns", () => { + const matcher = getPrimitiveUnionMatcherPartialW<"x" | "y" | "z">()({ + x: () => 1 as const, + _: () => "fallback" as const, + }); + expectTypeOf(matcher).toEqualTypeOf< + (shape: "x" | "y" | "z") => 1 | "fallback" + >(); + const factory = getPrimitiveUnionMatcherPartialW<"x" | "y">(); + expectTypeOf<{ + x: () => number; + }>().not.toMatchTypeOf[0]>(); + assert.equal(matcher("x"), 1); + assert.equal(matcher("y"), "fallback"); + assert.equal(matcher("z"), "fallback"); +});