From 40bf652b97d7ef6aec9987c8849f461d54cc408e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Wed, 16 Sep 2026 14:52:21 +0000 Subject: [PATCH 01/11] :sparkles: Add primitive union pattern matchers Curried matchers over string|number literal unions in four variants (exhaustive/partial x strict/widened return inference), per the header matrix in src/primitive.ts. - Adds type-fest for Simplify/ValueOf. - Deliberate oxlint escape hatches (as any dispatch) until a cast-free formulation lands; see the file comments. --- package-lock.json | 30 +++++++++++++++++++++++ package.json | 3 +++ src/primitive.ts | 61 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 src/primitive.ts diff --git a/package-lock.json b/package-lock.json index 16cfebb..a112ee6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "tiny-pattern-ts", "version": "0.1.8", "license": "MIT", + "dependencies": { + "type-fest": "^5.9.0" + }, "devDependencies": { "@arethetypeswrong/cli": "^0.18.5", "@runwisp/pubv": "^1.5.1", @@ -4622,6 +4625,18 @@ "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/test-exclude": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", @@ -4705,6 +4720,21 @@ "license": "0BSD", "optional": true }, + "node_modules/type-fest": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.9.0.tgz", + "integrity": "sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", diff --git a/package.json b/package.json index b68565c..16be35b 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,9 @@ "setup": "npm run setup:git-commit-message", "setup:git-commit-message": "git config commit.template commit-message-template" }, + "dependencies": { + "type-fest": "^5.9.0" + }, "devDependencies": { "@arethetypeswrong/cli": "^0.18.5", "@runwisp/pubv": "^1.5.1", diff --git a/src/primitive.ts b/src/primitive.ts new file mode 100644 index 0000000..04d411b --- /dev/null +++ b/src/primitive.ts @@ -0,0 +1,61 @@ +import type { Simplify, ValueOf } from "type-fest"; + +type UnaryFn = (shape: T) => R; + +// ============================================================================ +// ✔️ Exhaustive +// ❌ ReturnsStrict +// ============================================================================ +type PatternPrimitiveUnion = { + [K in T]: UnaryFn; +}; + +type PatternReturns< + P extends Record>, +> = ReturnType>; + +export const getPrimitiveUnionMatcherW: () => < + P extends PatternPrimitiveUnion, +>( + pattern: Simplify

, +) => UnaryFn> = () => (pattern) => (shape) => + // oxlint-disable-next-line typescript/no-explicit-any typescript/no-unsafe-type-assertion + (pattern[shape] as any)(shape); + +// ============================================================================ +// ✔️ Exhaustive +// ✔️ ReturnsStrict +// ============================================================================ +export const getPrimitiveUnionMatcher: () => ( + pattern: Simplify>, +) => UnaryFn = getPrimitiveUnionMatcherW; + +// ============================================================================ +// ❌ Exhaustive +// ✔️ ReturnsStrict +// ============================================================================ +type PatternPrimitiveUnionPartial = + | PatternPrimitiveUnion + | (Partial> & { + _: UnaryFn; + }); + +export const getPrimitiveUnionMatcherPartial: () => < + R, +>( + pattern: Simplify>, +) => UnaryFn = () => (pattern) => (shape) => + // oxlint-disable-next-line typescript/no-explicit-any typescript/no-unsafe-type-assertion + (pattern[shape] ?? (pattern as any)["_"])(shape); + +// ============================================================================ +// ❌ Exhaustive +// ❌ ReturnsStrict +// ============================================================================ +export const getPrimitiveUnionMatcherPartialW: < + T extends string | number, +>() =>

>( + pattern: Simplify

, + + // oxlint-disable-next-line typescript/no-explicit-any typescript/no-unsafe-type-assertion +) => UnaryFn> = getPrimitiveUnionMatcherPartial as any; 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 02/11] :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"); +}); From 34567856a5e6ec979d3cd72545cd7dff4cafcdf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Wed, 16 Sep 2026 16:00:41 +0000 Subject: [PATCH 03/11] :recycle: Label arrange-act-assert blocks in specs Rework every test body into // Arrange / // Act / // Assert blocks separated by blank lines: the factory is arranged once, the matcher is built from it in a single act, and all type/runtime checks sink to the end. index.test.ts adopts the same shape. Also migrate off expect-type's deprecated toMatchTypeOf: the checks are assignability tests, so toExtend is the faithful replacement. --- src/index.test.ts | 19 ++++++++++-- src/primitive.test.ts | 70 +++++++++++++++++++++++++++++++++++-------- 2 files changed, 74 insertions(+), 15 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index 5a1131b..ef6a58e 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -7,44 +7,59 @@ import { expectTypeOf } from "expect-type"; import { type Matcher, P, match } from "./index.ts"; test("match returns a builder", () => { + // Act const builder = match("x"); + + // Assert expectTypeOf(builder).toHaveProperty("with"); expectTypeOf(builder).toHaveProperty("exhaustive"); expectTypeOf(builder).toHaveProperty("otherwise"); }); test("P.literal narrows to its literal type", () => { + // Act const matcher = P.literal("yes"); - expectTypeOf(matcher).toMatchTypeOf>(); + + // Assert + expectTypeOf(matcher).toExtend>(); assert.equal(matcher.matches("yes"), true); assert.equal(matcher.matches("no"), false); }); test("P.type narrows to the typeof target", () => { + // Act const matcher = P.type("string"); - expectTypeOf(matcher).toMatchTypeOf>(); + + // Assert + expectTypeOf(matcher).toExtend>(); assert.equal(matcher.matches("hi"), true); assert.equal(matcher.matches(42), false); }); test("exhaustive() returns the union of handler return types", () => { + // Act const result = match<"a" | "b">("a") .with(P.literal("a"), () => 1 as const) .with(P.literal("b"), () => "two" as const) .exhaustive(); + // Assert expectTypeOf(result).toEqualTypeOf<1 | "two">(); assert.equal(result, 1); }); test("otherwise() falls back when no case matches", () => { + // Act const result = match<"x" | "y" | "z">("z") .with(P.literal("x"), (v): string => `got ${v}`) .otherwise((v): string => `fallback ${v}`); + + // Assert assert.equal(result, "fallback z"); }); test("exhaustive throws when no case matches", () => { + // Assert assert.throws( () => match<"a" | "b" | "c">("c") diff --git a/src/primitive.test.ts b/src/primitive.test.ts index a2be8e9..e50e57e 100644 --- a/src/primitive.test.ts +++ b/src/primitive.test.ts @@ -16,26 +16,37 @@ import { // ============================================================================ test("getPrimitiveUnionMatcherW requires every literal key", () => { - const matcher = getPrimitiveUnionMatcherW<"a" | "b">()({ + // Arrange + const factory = getPrimitiveUnionMatcherW<"a" | "b">(); + + // Act + const matcher = factory({ a: () => 1 as const, b: () => "two" as const, }); + + // Assert // ❌ 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]>(); + }>().not.toExtend[0]>(); assert.equal(matcher("a"), 1); assert.equal(matcher("b"), "two"); }); test("getPrimitiveUnionMatcherW dispatches on numeric literal keys", () => { - const matcher = getPrimitiveUnionMatcherW<1 | 2>()({ + // Arrange + const factory = getPrimitiveUnionMatcherW<1 | 2>(); + + // Act + const matcher = factory({ 1: (n) => n + 1, 2: (n) => n * 10, }); + + // Assert expectTypeOf(matcher).toEqualTypeOf<(shape: 1 | 2) => number>(); assert.equal(matcher(1), 2); assert.equal(matcher(2), 20); @@ -46,10 +57,16 @@ test("getPrimitiveUnionMatcherW dispatches on numeric literal keys", () => { // ============================================================================ test("getPrimitiveUnionMatcher infers a single return type shared by all handlers", () => { - const matcher = getPrimitiveUnionMatcher<"a" | "b">()({ + // Arrange + const factory = getPrimitiveUnionMatcher<"a" | "b">(); + + // Act + const matcher = factory({ a: (): number => 1, b: (): 1 | 2 => 2, }); + + // Assert // ✔️ ReturnsStrict: R is the best common return type, not a widening union. expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b") => number>(); assert.equal(matcher("a"), 1); @@ -57,7 +74,11 @@ test("getPrimitiveUnionMatcher infers a single return type shared by all handler }); test("getPrimitiveUnionMatcher handlers receive the matched literal", () => { - const matcher = getPrimitiveUnionMatcher<"on" | "off">()({ + // Arrange + const factory = getPrimitiveUnionMatcher<"on" | "off">(); + + // Act + const matcher = factory({ on: (s) => { expectTypeOf(s).toEqualTypeOf<"on">(); return `handler ${s}`; @@ -67,6 +88,8 @@ test("getPrimitiveUnionMatcher handlers receive the matched literal", () => { return `handler ${s}`; }, }); + + // Assert expectTypeOf(matcher).toEqualTypeOf<(shape: "on" | "off") => string>(); assert.equal(matcher("on"), "handler on"); assert.equal(matcher("off"), "handler off"); @@ -77,7 +100,13 @@ test("getPrimitiveUnionMatcher handlers receive the matched literal", () => { // ============================================================================ test("getPrimitiveUnionMatcherPartial routes shapes without a handler to _", () => { - const matcher = getPrimitiveUnionMatcherPartial<"a" | "b" | "c">()({ + // Arrange + const factory = getPrimitiveUnionMatcherPartial<"a" | "b" | "c">(); + // Two keys, so `{ a }` lacks only the `_` fallback, nothing else. + const sparseFactory = getPrimitiveUnionMatcherPartial<"a" | "b">(); + + // Act + const matcher = factory({ a: (): 1 | 2 => 1, _: (s): 1 | 2 => { // The fallback sees the whole union, not a single literal. @@ -85,22 +114,29 @@ test("getPrimitiveUnionMatcherPartial routes shapes without a handler to _", () return 2; }, }); + + // Assert 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]>(); + }>().not.toExtend[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">()({ + // Arrange + const factory = getPrimitiveUnionMatcherPartial<"a" | "b">(); + + // Act + const matcher = factory({ a: () => "A", b: () => "B", }); + + // Assert expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b") => string>(); assert.equal(matcher("a"), "A"); assert.equal(matcher("b"), "B"); @@ -111,17 +147,25 @@ test("getPrimitiveUnionMatcherPartial also accepts an exhaustive pattern", () => // ============================================================================ test("getPrimitiveUnionMatcherPartialW allows gaps and widens to the union of handler returns", () => { - const matcher = getPrimitiveUnionMatcherPartialW<"x" | "y" | "z">()({ + // Arrange + const factory = getPrimitiveUnionMatcherPartialW<"x" | "y" | "z">(); + // Two keys, so `{ x }` lacks only the `_` fallback, nothing else. + const sparseFactory = getPrimitiveUnionMatcherPartialW<"x" | "y">(); + + // Act + const matcher = factory({ x: () => 1 as const, _: () => "fallback" as const, }); + + // Assert expectTypeOf(matcher).toEqualTypeOf< (shape: "x" | "y" | "z") => 1 | "fallback" >(); - const factory = getPrimitiveUnionMatcherPartialW<"x" | "y">(); + // ❌ Exhaustive: gaps are allowed, but only with a `_` fallback. expectTypeOf<{ x: () => number; - }>().not.toMatchTypeOf[0]>(); + }>().not.toExtend[0]>(); assert.equal(matcher("x"), 1); assert.equal(matcher("y"), "fallback"); assert.equal(matcher("z"), "fallback"); From 62a5599ab355bd79d67f021324665cb6ff2cb77c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Wed, 16 Sep 2026 16:00:43 +0000 Subject: [PATCH 04/11] :memo: Require labeled AAA blocks in tests Rule in CONTRIBUTING.md; decision, why and rejected unlabeled ordering in development/testing.md. --- CONTRIBUTING.md | 6 ++++++ development/testing.md | 27 +++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7d69176..7c62e64 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,6 +67,12 @@ before the implementation. The loop is **type → red → green → refactor**: `npm run verify` as the definition-of-done gate. Every test pairs an `expectTypeOf(...)` with an `assert.*`; keep them together. +Each test body follows **AAA (Arrange–Act–Assert)** with labeled blocks +separated by a blank line: `// Arrange` sets up the inputs (e.g. the matcher +factory), `// Act` exercises the subject once from them (not a second +throwaway call), `// Assert` holds every check — type expectations first, +runtime assertions last; an empty block drops its label (see +[development/testing.md § AAA ordering](./development/testing.md#aaa-ordering)). Type-first is enforced structurally: `npm test` runs `check:tsc` before the test runner, so a wrong type can never be papered over by a passing assertion. Per [AGENTS.md § Never do](./AGENTS.md#never-do), reach green honestly — fix the diff --git a/development/testing.md b/development/testing.md index c58bbda..06a1986 100644 --- a/development/testing.md +++ b/development/testing.md @@ -36,6 +36,33 @@ The runner is `node --test --strip-types "src/**/*.test.ts"` and the tiers are i build step, and the runner relies on the `.ts` import-extension convention (see [tooling.md](./tooling.md#source-imports-use-ts-extensions)). +## AAA ordering + +The rule is in +[CONTRIBUTING.md § Testing discipline (type-driven)](../CONTRIBUTING.md#testing-discipline-type-driven). + +#### Decision (2026-09) + +Test bodies read arrange → act → assert: inputs (the factory) set up first, the +subject exercised once from them, all checks last — types then runtime. The +blocks are labeled with `// Arrange` / `// Act` / `// Assert` comments and +separated by a blank line; an empty block drops its label. + +#### Why + +- Interleaved setup/checks hide what runs vs. what is observed; the eye + re-reads the block to find the seams. +- A factory built mid-test invites a second throwaway call of the subject; + arranging it once makes the positive construction and the negative + `Parameters<…>` check share one source of truth. +- Labels make the seams explicit, not inferred — grep-able and reviewable + without reading the statements. + +#### Rejected + +- Unlabeled ordering (bare blank lines): the seams still have to be found by + reading; the labels cost nothing. + ## Known issues - The type-aware linter misidentifies `expectTypeOf()` as a floating promise, so From 2ff67801e078d208a565a0eac9ec55a5a1bab5c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Wed, 16 Sep 2026 17:32:08 +0000 Subject: [PATCH 05/11] =?UTF-8?q?=E2=9C=A8=20Add=20VS=20Code=20node:test?= =?UTF-8?q?=20debugging=20and=20runner=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit launch.json: 'Debug current test file' F5 config running node --test --strip-types on the active file. settings.json: register .ts with the connor4312.nodejs-testing extension via nodejs-testing.extensions, using --strip-types (the repo pins node >=26 with native type stripping) instead of tsx, so the extension discovers src/*.test.ts and shows Run/Debug lenses above test() calls in the native Testing UI. extensions.json: recommend connor4312.nodejs-testing; cspell.json: allow 'connor' for the extension id. .gitignore: un-ignore .vscode/launch.json so the config is shared. --- .gitignore | 3 ++- .vscode/extensions.json | 1 + .vscode/launch.json | 15 +++++++++++++++ .vscode/settings.json | 10 ++++++++++ cspell.json | 3 ++- 5 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 .vscode/launch.json diff --git a/.gitignore b/.gitignore index f0a5812..9ec4b74 100644 --- a/.gitignore +++ b/.gitignore @@ -10,5 +10,6 @@ coverage !.vscode/extensions.json !.vscode/settings.json !.vscode/tasks.json +!.vscode/launch.json .idea -.DS_Store \ No newline at end of file +.DS_Store diff --git a/.vscode/extensions.json b/.vscode/extensions.json index d826373..9002534 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,5 +1,6 @@ { "recommendations": [ + "connor4312.nodejs-testing", "oxc.oxc-vscode", "streetsidesoftware.code-spell-checker", "typescriptteam.native-preview", diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..668656c --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Debug current test file", + "runtimeExecutable": "node", + "runtimeArgs": ["--test", "--strip-types"], + "args": ["${file}"], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 4473f94..2021886 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,14 @@ { + "nodejs-testing.extensions": [ + { + "extensions": ["mjs", "cjs", "js"], + "parameters": [] + }, + { + "extensions": ["ts"], + "parameters": ["--strip-types"] + } + ], "[typescript]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true diff --git a/cspell.json b/cspell.json index bebc052..f0e5dff 100644 --- a/cspell.json +++ b/cspell.json @@ -42,7 +42,8 @@ "bestikk", "silverwind", "idris", - "todotasks" + "todotasks", + "connor" ], "ignorePaths": ["dist", "node_modules", "coverage", "*.svg", ".gitignore"] } From cce030b8e5c438c2443d79d6141622ee147a975a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Wed, 16 Sep 2026 19:08:06 +0000 Subject: [PATCH 06/11] :bug: Type partial W matcher without an any cast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getPrimitiveUnionMatcherPartialW was declared with the intended P-inferring signature but assigned via `as any`, hiding that the declared parameter was not assignable to the implementation's. The union in PatternPrimitiveUnionPartial makes the `_` arm demand `_ ∈ keyof P`, which the exhaustive arm cannot prove. Intersect the inference hook Simplify

with the implementation's parameter shape, R pinned to PatternReturns

, so the assignment type-checks while callers keep inferring P from the argument. Add a spec for the exhaustive (no `_`) pattern. --- src/primitive.test.ts | 17 +++++++++++++++++ src/primitive.ts | 14 ++++++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/primitive.test.ts b/src/primitive.test.ts index e50e57e..382cda0 100644 --- a/src/primitive.test.ts +++ b/src/primitive.test.ts @@ -170,3 +170,20 @@ test("getPrimitiveUnionMatcherPartialW allows gaps and widens to the union of ha assert.equal(matcher("y"), "fallback"); assert.equal(matcher("z"), "fallback"); }); + +test("getPrimitiveUnionMatcherPartialW also accepts an exhaustive pattern", () => { + // Arrange + const factory = getPrimitiveUnionMatcherPartialW<"x" | "y">(); + + // Act + const matcher = factory({ + x: () => 1 as const, + y: () => "two" as const, + }); + + // Assert + // ❌ ReturnsStrict: mixed handler returns widen to their union. + expectTypeOf(matcher).toEqualTypeOf<(shape: "x" | "y") => 1 | "two">(); + assert.equal(matcher("x"), 1); + assert.equal(matcher("y"), "two"); +}); diff --git a/src/primitive.ts b/src/primitive.ts index 04d411b..9ecae69 100644 --- a/src/primitive.ts +++ b/src/primitive.ts @@ -55,7 +55,13 @@ export const getPrimitiveUnionMatcherPartial: () => < export const getPrimitiveUnionMatcherPartialW: < T extends string | number, >() =>

>( - pattern: Simplify

, - - // oxlint-disable-next-line typescript/no-explicit-any typescript/no-unsafe-type-assertion -) => UnaryFn> = getPrimitiveUnionMatcherPartial as any; + /* + `Simplify

` is the inference hook: callers infer `P` from the argument. + the second half pins the impl parameter's `R` to `PatternReturns

`. + that makes the `= getPrimitiveUnionMatcherPartial` assignment type-check. + neither half works alone. + without the witness the union's `_` arm demands `_ ∈ keyof P`. + without `Simplify

` the parameter types do not compare. + */ + pattern: Simplify

& PatternPrimitiveUnionPartial, T>, +) => UnaryFn> = getPrimitiveUnionMatcherPartial; From ed71929365b4d9aed0e5f6a54359c6f88bda97f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Wed, 16 Sep 2026 19:29:04 +0000 Subject: [PATCH 07/11] :white_check_mark: Check matcher handler params The primitive union matchers invoke every handler with the matched literal, but the specs wrote their handlers with zero parameters, so the passed value and its inferred key-literal type went untested. Declare the parameter on each such handler and pin it with expectTypeOf, matching the style the two already-correct handlers used. The `_` fallbacks assert the whole union rather than a single literal. Return-type expectations are unchanged, so the W / non-W widening distinctions are still covered. --- src/primitive.test.ts | 66 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 53 insertions(+), 13 deletions(-) diff --git a/src/primitive.test.ts b/src/primitive.test.ts index 382cda0..1182311 100644 --- a/src/primitive.test.ts +++ b/src/primitive.test.ts @@ -21,8 +21,14 @@ test("getPrimitiveUnionMatcherW requires every literal key", () => { // Act const matcher = factory({ - a: () => 1 as const, - b: () => "two" as const, + a: (s) => { + expectTypeOf(s).toEqualTypeOf<"a">(); + return 1 as const; + }, + b: (s) => { + expectTypeOf(s).toEqualTypeOf<"b">(); + return "two" as const; + }, }); // Assert @@ -42,8 +48,14 @@ test("getPrimitiveUnionMatcherW dispatches on numeric literal keys", () => { // Act const matcher = factory({ - 1: (n) => n + 1, - 2: (n) => n * 10, + 1: (n) => { + expectTypeOf(n).toEqualTypeOf<1>(); + return n + 1; + }, + 2: (n) => { + expectTypeOf(n).toEqualTypeOf<2>(); + return n * 10; + }, }); // Assert @@ -62,8 +74,14 @@ test("getPrimitiveUnionMatcher infers a single return type shared by all handler // Act const matcher = factory({ - a: (): number => 1, - b: (): 1 | 2 => 2, + a: (s): number => { + expectTypeOf(s).toEqualTypeOf<"a">(); + return 1; + }, + b: (s): 1 | 2 => { + expectTypeOf(s).toEqualTypeOf<"b">(); + return 2; + }, }); // Assert @@ -107,7 +125,10 @@ test("getPrimitiveUnionMatcherPartial routes shapes without a handler to _", () // Act const matcher = factory({ - a: (): 1 | 2 => 1, + a: (s): 1 | 2 => { + expectTypeOf(s).toEqualTypeOf<"a">(); + return 1; + }, _: (s): 1 | 2 => { // The fallback sees the whole union, not a single literal. expectTypeOf(s).toEqualTypeOf<"a" | "b" | "c">(); @@ -132,8 +153,14 @@ test("getPrimitiveUnionMatcherPartial also accepts an exhaustive pattern", () => // Act const matcher = factory({ - a: () => "A", - b: () => "B", + a: (s) => { + expectTypeOf(s).toEqualTypeOf<"a">(); + return "A"; + }, + b: (s) => { + expectTypeOf(s).toEqualTypeOf<"b">(); + return "B"; + }, }); // Assert @@ -154,8 +181,15 @@ test("getPrimitiveUnionMatcherPartialW allows gaps and widens to the union of ha // Act const matcher = factory({ - x: () => 1 as const, - _: () => "fallback" as const, + x: (s) => { + expectTypeOf(s).toEqualTypeOf<"x">(); + return 1 as const; + }, + _: (s) => { + // The fallback sees the whole union, not a single literal. + expectTypeOf(s).toEqualTypeOf<"x" | "y" | "z">(); + return "fallback" as const; + }, }); // Assert @@ -177,8 +211,14 @@ test("getPrimitiveUnionMatcherPartialW also accepts an exhaustive pattern", () = // Act const matcher = factory({ - x: () => 1 as const, - y: () => "two" as const, + x: (s) => { + expectTypeOf(s).toEqualTypeOf<"x">(); + return 1 as const; + }, + y: (s) => { + expectTypeOf(s).toEqualTypeOf<"y">(); + return "two" as const; + }, }); // Assert From a0f0894339bc1aac0def5cf41d747b5987b9f518 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Wed, 16 Sep 2026 20:01:12 +0000 Subject: [PATCH 08/11] :white_check_mark: Spec mixed string and numeric union keys --- src/primitive.test.ts | 128 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/src/primitive.test.ts b/src/primitive.test.ts index 1182311..ee9f57e 100644 --- a/src/primitive.test.ts +++ b/src/primitive.test.ts @@ -64,6 +64,40 @@ test("getPrimitiveUnionMatcherW dispatches on numeric literal keys", () => { assert.equal(matcher(2), 20); }); +test("getPrimitiveUnionMatcherW dispatches on mixed string and numeric keys", () => { + // Arrange + const factory = getPrimitiveUnionMatcherW<"a" | "b" | 1 | 2>(); + + // Act + const matcher = factory({ + a: (s) => { + expectTypeOf(s).toEqualTypeOf<"a">(); + return "A" as const; + }, + b: (s) => { + expectTypeOf(s).toEqualTypeOf<"b">(); + return "B" as const; + }, + 1: (n) => { + expectTypeOf(n).toEqualTypeOf<1>(); + return 10 as const; + }, + 2: (n) => { + expectTypeOf(n).toEqualTypeOf<2>(); + return 20 as const; + }, + }); + + // Assert + expectTypeOf(matcher).toEqualTypeOf< + (shape: "a" | "b" | 1 | 2) => "A" | "B" | 10 | 20 + >(); + assert.equal(matcher("a"), "A"); + assert.equal(matcher("b"), "B"); + assert.equal(matcher(1), 10); + assert.equal(matcher(2), 20); +}); + // ============================================================================ // API: getPrimitiveUnionMatcher — ✔️ Exhaustive / ✔️ ReturnsStrict // ============================================================================ @@ -113,6 +147,39 @@ test("getPrimitiveUnionMatcher handlers receive the matched literal", () => { assert.equal(matcher("off"), "handler off"); }); +test("getPrimitiveUnionMatcher infers one return type across mixed string and numeric keys", () => { + // Arrange + const factory = getPrimitiveUnionMatcher<"a" | "b" | 1 | 2>(); + + // Act + const matcher = factory({ + a: (s): number => { + expectTypeOf(s).toEqualTypeOf<"a">(); + return 1; + }, + b: (s): 1 | 2 => { + expectTypeOf(s).toEqualTypeOf<"b">(); + return 2; + }, + 1: (n): number => { + expectTypeOf(n).toEqualTypeOf<1>(); + return 10; + }, + 2: (n): number => { + expectTypeOf(n).toEqualTypeOf<2>(); + return 20; + }, + }); + + // Assert + // ✔️ ReturnsStrict: R is the best common return type, not a widening union. + expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b" | 1 | 2) => number>(); + assert.equal(matcher("a"), 1); + assert.equal(matcher("b"), 2); + assert.equal(matcher(1), 10); + assert.equal(matcher(2), 20); +}); + // ============================================================================ // API: getPrimitiveUnionMatcherPartial — ❌ Exhaustive / ✔️ ReturnsStrict // ============================================================================ @@ -169,6 +236,35 @@ test("getPrimitiveUnionMatcherPartial also accepts an exhaustive pattern", () => assert.equal(matcher("b"), "B"); }); +test("getPrimitiveUnionMatcherPartial routes mixed string and numeric keys, gaps go to _", () => { + // Arrange + const factory = getPrimitiveUnionMatcherPartial<"a" | "b" | 1 | 2>(); + + // Act + const matcher = factory({ + a: (s): string => { + expectTypeOf(s).toEqualTypeOf<"a">(); + return "A"; + }, + 1: (n): string => { + expectTypeOf(n).toEqualTypeOf<1>(); + return "one"; + }, + _: (s): string => { + // The fallback sees the whole union, not a single literal. + expectTypeOf(s).toEqualTypeOf<"a" | "b" | 1 | 2>(); + return "fallback"; + }, + }); + + // Assert + expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b" | 1 | 2) => string>(); + assert.equal(matcher("a"), "A"); + assert.equal(matcher("b"), "fallback"); + assert.equal(matcher(1), "one"); + assert.equal(matcher(2), "fallback"); +}); + // ============================================================================ // API: getPrimitiveUnionMatcherPartialW — ❌ Exhaustive / ❌ ReturnsStrict // ============================================================================ @@ -205,6 +301,38 @@ test("getPrimitiveUnionMatcherPartialW allows gaps and widens to the union of ha assert.equal(matcher("z"), "fallback"); }); +test("getPrimitiveUnionMatcherPartialW widens mixed string and numeric key returns to their union", () => { + // Arrange + const factory = getPrimitiveUnionMatcherPartialW<"a" | "b" | 1 | 2>(); + + // Act + const matcher = factory({ + a: (s) => { + expectTypeOf(s).toEqualTypeOf<"a">(); + return "A" as const; + }, + 1: (n) => { + expectTypeOf(n).toEqualTypeOf<1>(); + return 10 as const; + }, + _: (s) => { + // The fallback sees the whole union, not a single literal. + expectTypeOf(s).toEqualTypeOf<"a" | "b" | 1 | 2>(); + return "fallback" as const; + }, + }); + + // Assert + // ❌ ReturnsStrict: mixed handler returns widen to their union. + expectTypeOf(matcher).toEqualTypeOf< + (shape: "a" | "b" | 1 | 2) => "A" | 10 | "fallback" + >(); + assert.equal(matcher("a"), "A"); + assert.equal(matcher("b"), "fallback"); + assert.equal(matcher(1), 10); + assert.equal(matcher(2), "fallback"); +}); + test("getPrimitiveUnionMatcherPartialW also accepts an exhaustive pattern", () => { // Arrange const factory = getPrimitiveUnionMatcherPartialW<"x" | "y">(); From 16650b07e3d6c6ad70b4c3ad405646d48176df16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Wed, 16 Sep 2026 20:01:20 +0000 Subject: [PATCH 09/11] :memo: Backlog primitive literal coverage questions --- backlog.tasks | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backlog.tasks b/backlog.tasks index 6ec352e..b405f19 100644 --- a/backlog.tasks +++ b/backlog.tasks @@ -20,6 +20,8 @@ v1.0: Bugs: Enhancements: +☐ Allow boolean literals in primitive union patterns (e.g. `true: () => "yes"`) @medium +☐ Are there other primitive types that should be supported in union patterns? (e.g. `bigint`, `symbol`) @medium Documentation: ☐ Create `examples/` directory with runnable snippets From 8e2691e6aa2ad73546f58d652b45a164ccca6c89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Wed, 16 Sep 2026 20:41:26 +0000 Subject: [PATCH 10/11] :memo: Will add backlog items found while working --- backlog.tasks | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backlog.tasks b/backlog.tasks index b405f19..2e5197b 100644 --- a/backlog.tasks +++ b/backlog.tasks @@ -6,6 +6,10 @@ Backlog and tracking for tiny-pattern-ts. Managed in vscode-todotasks format. Setup: ☐ Split off template into separate package => pi --session 01a07dde-7050-7054-bb36-1606d7eb2bc3 @high +☐ straighten oxc rules + ☐ oxc forbids ternary => remove oxc rule + ☐ oxc wants comments to start comments with a capital letter => remove oxc rule + ☐ there already exists a /* */ block comment in primitive.ts, => change to multi-line comment v1.0: ☐ API surface is stable and fully typed From c13bc2f408b6f18ffc13d8c592715f87663aafc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Wed, 16 Sep 2026 21:39:46 +0000 Subject: [PATCH 11/11] :memo: Document why any is retained in primitive matchers Rewriting without any was evaluated; the alternatives were more complex than the current solution. Record the rationale next to the two oxlint-disable sites so the suppression is not re-litigated. --- src/primitive.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/primitive.ts b/src/primitive.ts index 9ecae69..6759784 100644 --- a/src/primitive.ts +++ b/src/primitive.ts @@ -19,6 +19,11 @@ export const getPrimitiveUnionMatcherW: () => < >( pattern: Simplify

, ) => UnaryFn> = () => (pattern) => (shape) => + /* + Rewrite not to use any is possible, was evaluated and solutions were + more complex than the current solution. + */ + // oxlint-disable-next-line typescript/no-explicit-any typescript/no-unsafe-type-assertion (pattern[shape] as any)(shape); @@ -45,6 +50,11 @@ export const getPrimitiveUnionMatcherPartial: () => < >( pattern: Simplify>, ) => UnaryFn = () => (pattern) => (shape) => + /* + Rewrite not to use any is possible, was evaluated and solutions were + more complex than the current solution. + */ + // oxlint-disable-next-line typescript/no-explicit-any typescript/no-unsafe-type-assertion (pattern[shape] ?? (pattern as any)["_"])(shape);