From f485b1bae2de8d9f3780877415d9de60a9de39f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Wed, 23 Sep 2026 21:39:15 +0000 Subject: [PATCH 1/7] :sparkles: Finalize and document the public API surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export the matcher builder types from the barrel and rename them off the internal Strict/Widening suffixes, so the type a factory returns is nameable: PrimitiveUnionMatcher/W, TaggedUnionMatcher/W, and the tagged-union factory types. Add TSDoc to every public symbol — the declarations carry it into the published package — and pin the exported names in src/index.test.ts. Document the API in README and record the surface decision (and the rejected matcher-shared export) in development/library.md. --- CHANGELOG.md | 6 +++ README.md | 74 ++++++++++++++++++++++++++++++++++- backlog.tasks | 8 ++-- development/library.md | 37 +++++++++++++++++- src/index.test.ts | 49 ++++++++++++++++++++++++ src/index.ts | 18 +++++++++ src/primitive-union.ts | 58 ++++++++++++++++++++++++++-- src/tagged-union.ts | 87 +++++++++++++++++++++++++++++++++++++----- 8 files changed, 317 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c56704..278efb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- export the matcher builder types from the entry point — + `PrimitiveUnionMatcher` / `PrimitiveUnionMatcherW`, `TaggedUnionMatcher` / + `TaggedUnionMatcherW`, and the tagged-union factory types — and add TSDoc to + every public symbol +- document the public API in the README + ## [0.8.1] - 2026-09-23 - gate CI at 100% coverage: `test:ci` runs c8 with `--all --100` over `src/`, diff --git a/README.md b/README.md index c6fb26e..edc9eea 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,79 @@ for the design decisions and [Caveats](#caveats) for the limits. ## API -Yet to be implemented +The package exports four factories and the builder types they return. Each +factory comes in a _strict_ variant (one common return type `R`) and a +_widening_ variant (the union of every handler's return type): + +| Factory | Builder type | Return of the matcher | +| -------------------------------- | ------------------------------- | -------------------------------- | +| `getPrimitiveUnionMatcher()` | `PrimitiveUnionMatcher` | one common `R` | +| `getPrimitiveUnionMatcherW()` | `PrimitiveUnionMatcherW` | the union of the handler returns | +| `getTaggedUnionMatcher()` | `TaggedUnionMatcherFactory` | one common `R` | +| `getTaggedUnionMatcherW()` | `TaggedUnionMatcherWFactory` | the union of the handler returns | + +### Primitive-union matchers + +`getPrimitiveUnionMatcher()` takes the finite universe `T` and returns a +builder. Calling the builder with a handler map keyed by `T`'s members returns a +matcher: a function from `T` to the common return type. + +```ts +import { getPrimitiveUnionMatcher } from "tiny-pattern-ts"; + +const reply = getPrimitiveUnionMatcher<"yes" | "no">()({ + yes: () => "agreed", + no: () => "declined", +}); + +reply("yes"); // "agreed" +``` + +Add a fallback as the second argument to leave members unhandled; the fallback +receives the remainder: + +```ts +const label = getPrimitiveUnionMatcher<"yes" | "no" | "maybe">()( + { yes: () => "agreed", no: () => "declined" }, + (other) => `not sure: ${other}`, // other: "maybe" +); +``` + +`getPrimitiveUnionMatcherW` is the same builder, but the matcher's return type +is the union of the handler return types rather than one common `R`. + +### Tagged-union matchers + +`getTaggedUnionMatcher()` takes a discriminated union `T`. The returned +function takes the discriminant property's name and returns the handler-map +builder, keyed by that property's tags. + +```ts +import { getTaggedUnionMatcher } from "tiny-pattern-ts"; + +type Shape = + { kind: "circle"; radius: number } | { kind: "square"; side: number }; + +const area = getTaggedUnionMatcher()("kind")({ + circle: (s) => Math.PI * s.radius ** 2, + square: (s) => s.side ** 2, +}); + +area({ kind: "circle", radius: 2 }); +``` + +`getTaggedUnionMatcherW` is the widening counterpart, exactly as in the +primitive-union pair. The discriminant key is restricted to properties whose +values are tags; see [Caveats](#caveats) for the supported tags and the +`boolean` / `null` / `undefined` key projection. + +### The universe + +The primitive universe `T` must be a finite union of literals with no +value/stringification collision. A broad member (`string`, `number`, a template +literal) or a colliding pair (`true | "true"`, `1 | "1"`) is rejected at the +factory. The reasons and the rejected alternatives are in +[development/library.md](./development/library.md). ## Caveats diff --git a/backlog.tasks b/backlog.tasks index 4c74fb7..9d32d75 100644 --- a/backlog.tasks +++ b/backlog.tasks @@ -8,10 +8,10 @@ Setup: ☐ Split off template into separate package => pi --session 01a07dde-7050-7054-bb36-1606d7eb2bc3 @low v1.0: -☐ API surface is stable and fully typed - ☐ Finalize public exports in `src/index.ts` - ☐ Document all exported types and functions - ☐ Add JSDoc for public APIs +✔ API surface is stable and fully typed @done + ✔ Finalize public exports in `src/index.ts` @done + ✔ Document all exported types and functions @done + ✔ Add JSDoc for public APIs @done ✔ Test coverage meets threshold @done ✔ Achieve 100% branch coverage on `src/primitive-union.ts` @done ✔ Achieve 100% branch coverage on `src/index.ts` @done diff --git a/development/library.md b/development/library.md index fc3a3f6..f1d3db1 100644 --- a/development/library.md +++ b/development/library.md @@ -5,8 +5,41 @@ user-facing reference is [README § API](../README.md#api). The matchers are implemented in `src/primitive-union.ts` (`getPrimitiveUnionMatcher` / `getPrimitiveUnionMatcherW`) and `src/tagged-union.ts` (`getTaggedUnionMatcher` / -`getTaggedUnionMatcherW`), re-exported from `src/index.ts`; the rest of the -library is placeholder code. +`getTaggedUnionMatcherW`), re-exported from `src/index.ts`; the rest of `src/` +is implementation detail. + +## Public surface + +#### Decision (2026-09) + +`src/index.ts` exports the four factories and the builder types they return: +`PrimitiveUnionMatcher` / `PrimitiveUnionMatcherW`, `TaggedUnionMatcher` / +`TaggedUnionMatcherW`, and `TaggedUnionMatcherFactory` / +`TaggedUnionMatcherWFactory`. Every exported symbol carries TSDoc. The +universe-agnostic pieces in `matcher-shared.ts` stay internal. + +#### Why + +- The builder interfaces are already the inferred return types (they appear in + the emitted `.d.ts`), so exporting them only makes them nameable — a consumer + can annotate a factory result without restating its structure. +- The `Strict` / `Widening` suffixes were dropped for the public names because + the factory names already carry the `W` axis; the type and function names line + up (`getPrimitiveUnionMatcher` → `PrimitiveUnionMatcher`). +- TSDoc travels into the emitted declarations, so editor hovers and the + published package document the API without a hand-written `.d.ts`. +- `matcher-shared.ts` stays internal: its types are plumbing (`Handlers`, + `Fallback`, `UniverseGate`, …) whose shape follows the implementation, and the + matchers are the stable contract. + +#### Rejected + +- **Exporting the `matcher-shared.ts` vocabulary** (`Matchable`, `UnaryFn`, + `PatternKey`, `Member`, `PatternReturns`, …). They appear in the public + signatures, but a consumer never needs to name them; exporting them would + freeze internals as API. +- **A hand-written `.d.ts` or a separate API document.** It would drift from the + implementation; TSDoc is generated from the source. ## Matcher shape diff --git a/src/index.test.ts b/src/index.test.ts index 861a941..ac45a15 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -8,8 +8,18 @@ import { getPrimitiveUnionMatcherW, getTaggedUnionMatcher, getTaggedUnionMatcherW, + type PrimitiveUnionMatcher, + type PrimitiveUnionMatcherW, + type TaggedUnionMatcher, + type TaggedUnionMatcherFactory, + type TaggedUnionMatcherW, + type TaggedUnionMatcherWFactory, } from "./index.ts"; +interface Shape { + readonly kind: "circle" | "square"; +} + // The published entry point is the barrel (`package.json` exports // `./dist/index.js`), so every factory must be reachable from here. Importing it // also loads the module, which is what lets c8's `--all` measure it — see @@ -25,3 +35,42 @@ test("index: the public entry point re-exports every matcher factory", () => { expectTypeOf(getTaggedUnionMatcherW).toBeFunction(); assert.equal(typeof getTaggedUnionMatcherW, "function"); }); + +// The builder types are part of the public contract: a consumer annotates a +// factory result with them, so the exported names must match the inferred +// return types. +test("index: the primitive-union factories return the exported builder types", () => { + // Act + const strict = getPrimitiveUnionMatcher<"a" | "b">(); + const widening = getPrimitiveUnionMatcherW<"a" | "b">(); + + // Assert + expectTypeOf(strict).toEqualTypeOf>(); + assert.equal(typeof strict, "function"); + expectTypeOf(widening).toEqualTypeOf>(); + assert.equal(typeof widening, "function"); +}); + +test("index: the tagged-union factories return the exported factory types", () => { + // Act + const strict = getTaggedUnionMatcher(); + const widening = getTaggedUnionMatcherW(); + + // Assert + expectTypeOf(strict).toEqualTypeOf>(); + assert.equal(typeof strict, "function"); + expectTypeOf(widening).toEqualTypeOf>(); + assert.equal(typeof widening, "function"); +}); + +test("index: the tagged-union key step returns the exported builder types", () => { + // Act + const strict = getTaggedUnionMatcher()("kind"); + const widening = getTaggedUnionMatcherW()("kind"); + + // Assert + expectTypeOf(strict).toEqualTypeOf>(); + assert.equal(typeof strict, "function"); + expectTypeOf(widening).toEqualTypeOf>(); + assert.equal(typeof widening, "function"); +}); diff --git a/src/index.ts b/src/index.ts index 608736e..62d22fd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,26 @@ +/** + * The public entry point of `tiny-pattern-ts`. + * + * Exports the four matcher factories and the builder types they return. + * Everything else in `src/` is an implementation detail. + * + * @module + */ export { getPrimitiveUnionMatcher, getPrimitiveUnionMatcherW, } from "./primitive-union.ts"; +export type { + PrimitiveUnionMatcher, + PrimitiveUnionMatcherW, +} from "./primitive-union.ts"; export { getTaggedUnionMatcher, getTaggedUnionMatcherW, } from "./tagged-union.ts"; +export type { + TaggedUnionMatcher, + TaggedUnionMatcherFactory, + TaggedUnionMatcherW, + TaggedUnionMatcherWFactory, +} from "./tagged-union.ts"; diff --git a/src/primitive-union.ts b/src/primitive-union.ts index f3b328b..34a95e9 100644 --- a/src/primitive-union.ts +++ b/src/primitive-union.ts @@ -44,7 +44,14 @@ type MustBePartial = // Strict returns: one common `R`. Overload order is load-bearing: // #1 Handlers (first) -> the exhaustive form and the autocomplete popup // #2 Fallback (last) -> accepts a partial handler map plus a fallback -interface PrimitiveUnionMatcherStrict { +/** + * The builder returned by {@link getPrimitiveUnionMatcher}: call it with a + * handler map for every member of `T` to get a strict matcher, or with a partial + * map plus a fallback. + * + * @typeParam T - The finite universe of primitive members to match. + */ +export interface PrimitiveUnionMatcher { (handlers: Handlers & UniverseGate): UnaryFn; < R, @@ -59,7 +66,14 @@ interface PrimitiveUnionMatcherStrict { // Widened returns: the union of every handler's return type. `P` is inferred // from the whole handler map, whose closed constraint supplies the // contextual/autocomplete type. -interface PrimitiveUnionMatcherWidening { +/** + * The builder returned by {@link getPrimitiveUnionMatcherW}: like + * {@link PrimitiveUnionMatcher}, but the result's return type is the union of + * every handler's return type instead of one common `R`. + * + * @typeParam T - The finite universe of primitive members to match. + */ +export interface PrimitiveUnionMatcherW {

, P>>( handlers: P & UniverseGate, ): UnaryFn>; @@ -96,9 +110,45 @@ const dispatch = shape as never, ); +/** + * Create a matcher for a finite primitive universe, with one common return type. + * + * The universe `T` must be a finite union of literals with no + * value/stringification collision: broad members (`string`, `number`, template + * literals) and `true | "true"` / `1 | "1"` are rejected at the call site. The + * returned builder takes a handler map keyed by the members; supplying a second + * fallback argument allows a partial map and receives the unhandled remainder. + * + * @typeParam T - The finite universe of primitive members to match. + * @returns A builder for the handler map, or the handler map plus a fallback. + * @example + * const describe = getPrimitiveUnionMatcher<"yes" | "no">()({ + * yes: () => "agreed", + * no: () => "declined", + * }); + * describe("yes"); // "agreed" + */ export const getPrimitiveUnionMatcher = < T extends Matchable, ->(): PrimitiveUnionMatcherStrict => dispatch; +>(): PrimitiveUnionMatcher => dispatch; + +/** + * Create a matcher for a finite primitive universe whose return type is the + * union of every handler's return type. + * + * The widening counterpart of {@link getPrimitiveUnionMatcher}: use it when the + * handlers return different types and the union, not one common `R`, is wanted. + * The universe constraint and the optional fallback are identical. + * + * @typeParam T - The finite universe of primitive members to match. + * @returns A builder for the handler map, or the handler map plus a fallback. + * @example + * const reply = getPrimitiveUnionMatcherW<"yes" | "no">()({ + * yes: () => 1, + * no: () => "declined", + * }); + * // reply: (shape: "yes" | "no") => number | string + */ export const getPrimitiveUnionMatcherW = < T extends Matchable, ->(): PrimitiveUnionMatcherWidening => dispatch; +>(): PrimitiveUnionMatcherW => dispatch; diff --git a/src/tagged-union.ts b/src/tagged-union.ts index b43e9ad..58199b1 100644 --- a/src/tagged-union.ts +++ b/src/tagged-union.ts @@ -87,7 +87,15 @@ type MustBePartial = // Strict returns: one common `R`. Overload order is load-bearing: // #1 Handlers (first) -> the exhaustive form and the autocomplete popup // #2 Fallback (last) -> accepts a partial handler map plus a fallback -interface TaggedUnionMatcherStrict { +/** + * The builder returned by the key step of {@link getTaggedUnionMatcher}: call it + * with a handler map for every tag under `K` to get a strict matcher, or with a + * partial map plus a fallback. + * + * @typeParam T - The discriminated-union type to match. + * @typeParam K - The discriminant property of `T`. + */ +export interface TaggedUnionMatcher { (handlers: Handlers & UniverseGate>): UnaryFn; < R, @@ -104,7 +112,15 @@ interface TaggedUnionMatcherStrict { // Widened returns: the union of every handler's return type. `P` is inferred // from the whole handler map, whose closed constraint supplies the // contextual/autocomplete type. -interface TaggedUnionMatcherWidening { +/** + * The builder returned by the key step of {@link getTaggedUnionMatcherW}: like + * {@link TaggedUnionMatcher}, but the result's return type is the union of every + * handler's return type instead of one common `R`. + * + * @typeParam T - The discriminated-union type to match. + * @typeParam K - The discriminant property of `T`. + */ +export interface TaggedUnionMatcherW {

, P>>( handlers: P & UniverseGate>, ): UnaryFn>; @@ -121,15 +137,29 @@ interface TaggedUnionMatcherWidening { // The key-taking step of the curried factory. Naming it lets the factory return // `dispatch` directly, the tacit twin of the primitive-union factory's bare // `=> dispatch`. -type TaggedUnionMatcherFactory = >( - k: K, -) => TaggedUnionMatcherStrict; - -type TaggedUnionMatcherWideningFactory = < +/** + * The function returned by {@link getTaggedUnionMatcher}: call it with the + * discriminant property's name to get the handler-map builder. + * + * @typeParam T - The discriminated-union type to match. + */ +export type TaggedUnionMatcherFactory = < K extends Discriminated, >( k: K, -) => TaggedUnionMatcherWidening; +) => TaggedUnionMatcher; + +/** + * The function returned by {@link getTaggedUnionMatcherW}: call it with the + * discriminant property's name to get the widening handler-map builder. + * + * @typeParam T - The discriminated-union type to match. + */ +export type TaggedUnionMatcherWFactory = < + K extends Discriminated, +>( + k: K, +) => TaggedUnionMatcherW; const dispatch = (k: PropertyKey) => @@ -152,10 +182,49 @@ const dispatch = ); }; +/** + * Create a matcher for a discriminated union, with one common return type. + * + * The first call fixes the union `T`; the returned function takes the + * discriminant property's name (`K`, restricted to properties whose values are + * tags), and that returns the handler-map builder. Supplying a second fallback + * argument to the builder allows a partial map and receives the members whose + * tag was not handled. A `boolean`, `null` or `undefined` tag is keyed by its + * stringified form (`true` -> `"true"`); see README § Caveats. + * + * @typeParam T - The discriminated-union type to match. + * @returns A function that takes the discriminant property's name. + * @example + * type Shape = + * | { kind: "circle"; radius: number } + * | { kind: "square"; side: number }; + * + * const area = getTaggedUnionMatcher()("kind")({ + * circle: (s) => Math.PI * s.radius ** 2, + * square: (s) => s.side ** 2, + * }); + */ export const getTaggedUnionMatcher = < T extends object, >(): TaggedUnionMatcherFactory => dispatch; +/** + * Create a matcher for a discriminated union whose return type is the union of + * every handler's return type. + * + * The widening counterpart of {@link getTaggedUnionMatcher}: use it when the + * handlers return different types and the union, not one common `R`, is wanted. + * The curried key step and the optional fallback are identical. + * + * @typeParam T - The discriminated-union type to match. + * @returns A function that takes the discriminant property's name. + * @example + * const describe = getTaggedUnionMatcherW()("kind")({ + * circle: () => "round", + * square: () => 4, + * }); + * // describe: (shape: Shape) => string | number + */ export const getTaggedUnionMatcherW = < T extends object, ->(): TaggedUnionMatcherWideningFactory => dispatch; +>(): TaggedUnionMatcherWFactory => dispatch; From 9c9468fa3c1a18a8e6980c90407116237532bf1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Wed, 23 Sep 2026 22:11:22 +0000 Subject: [PATCH 2/7] :memo: Explain the W widening suffix in the API section State that the `W` suffix means widening and what that widens: the matcher's return value goes from one common `R` to the union of every handler's return type. --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index edc9eea..475c998 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,8 @@ for the design decisions and [Caveats](#caveats) for the limits. The package exports four factories and the builder types they return. Each factory comes in a _strict_ variant (one common return type `R`) and a -_widening_ variant (the union of every handler's return type): +_widening_ variant: the `W` suffix means **widening** — the return value is +widened from one common `R` to the union of every handler's return type. | Factory | Builder type | Return of the matcher | | -------------------------------- | ------------------------------- | -------------------------------- | From 5ddbbdc183f108b3628203199c9d2be4069df945 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Wed, 23 Sep 2026 22:14:04 +0000 Subject: [PATCH 3/7] :recycle: Keep the public API to the four factories The builder types are already the inferred return types and travel into the emitted .d.ts, so exporting them only made them nameable while pinning the internal Strict/Widening split as API. Revert the type exports and the public renames, drop the type-surface test, and document only the factories in README and development/library.md. --- CHANGELOG.md | 5 +---- README.md | 20 ++++++++--------- development/library.md | 28 +++++++++++------------- src/index.test.ts | 49 ------------------------------------------ src/index.ts | 14 ++---------- src/primitive-union.ts | 22 ++++--------------- src/tagged-union.ts | 44 ++++++------------------------------- 7 files changed, 37 insertions(+), 145 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 278efb6..fa2779d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- export the matcher builder types from the entry point — - `PrimitiveUnionMatcher` / `PrimitiveUnionMatcherW`, `TaggedUnionMatcher` / - `TaggedUnionMatcherW`, and the tagged-union factory types — and add TSDoc to - every public symbol +- add TSDoc to the four public matcher factories - document the public API in the README ## [0.8.1] - 2026-09-23 diff --git a/README.md b/README.md index 475c998..ed02e3f 100644 --- a/README.md +++ b/README.md @@ -21,17 +21,17 @@ for the design decisions and [Caveats](#caveats) for the limits. ## API -The package exports four factories and the builder types they return. Each -factory comes in a _strict_ variant (one common return type `R`) and a -_widening_ variant: the `W` suffix means **widening** — the return value is -widened from one common `R` to the union of every handler's return type. +The package exports four factories. Each comes in a _strict_ variant (one common +return type `R`) and a _widening_ variant: the `W` suffix means **widening** — +the return value is widened from one common `R` to the union of every handler's +return type. -| Factory | Builder type | Return of the matcher | -| -------------------------------- | ------------------------------- | -------------------------------- | -| `getPrimitiveUnionMatcher()` | `PrimitiveUnionMatcher` | one common `R` | -| `getPrimitiveUnionMatcherW()` | `PrimitiveUnionMatcherW` | the union of the handler returns | -| `getTaggedUnionMatcher()` | `TaggedUnionMatcherFactory` | one common `R` | -| `getTaggedUnionMatcherW()` | `TaggedUnionMatcherWFactory` | the union of the handler returns | +| Factory | Return of the matcher | +| -------------------------------- | -------------------------------- | +| `getPrimitiveUnionMatcher()` | one common `R` | +| `getPrimitiveUnionMatcherW()` | the union of the handler returns | +| `getTaggedUnionMatcher()` | one common `R` | +| `getTaggedUnionMatcherW()` | the union of the handler returns | ### Primitive-union matchers diff --git a/development/library.md b/development/library.md index f1d3db1..909db0e 100644 --- a/development/library.md +++ b/development/library.md @@ -12,32 +12,30 @@ is implementation detail. #### Decision (2026-09) -`src/index.ts` exports the four factories and the builder types they return: -`PrimitiveUnionMatcher` / `PrimitiveUnionMatcherW`, `TaggedUnionMatcher` / -`TaggedUnionMatcherW`, and `TaggedUnionMatcherFactory` / -`TaggedUnionMatcherWFactory`. Every exported symbol carries TSDoc. The -universe-agnostic pieces in `matcher-shared.ts` stay internal. +`src/index.ts` exports the four factories and nothing else. Every exported +function carries TSDoc; the builder types and the `matcher-shared.ts` +vocabulary stay internal. #### Why -- The builder interfaces are already the inferred return types (they appear in - the emitted `.d.ts`), so exporting them only makes them nameable — a consumer - can annotate a factory result without restating its structure. -- The `Strict` / `Widening` suffixes were dropped for the public names because - the factory names already carry the `W` axis; the type and function names line - up (`getPrimitiveUnionMatcher` → `PrimitiveUnionMatcher`). +- The factories are the whole contract: a consumer calls one and never needs to + name the builder type it returns. +- The builder interfaces are already the inferred return types, so they travel + into the emitted `.d.ts` regardless. Exporting them would only make them + nameable while freezing the internal `Strict` / `Widening` overload split as + API. - TSDoc travels into the emitted declarations, so editor hovers and the published package document the API without a hand-written `.d.ts`. -- `matcher-shared.ts` stays internal: its types are plumbing (`Handlers`, - `Fallback`, `UniverseGate`, …) whose shape follows the implementation, and the - matchers are the stable contract. #### Rejected +- **Exporting the builder types** (`PrimitiveUnionMatcher`, …). Nameable, but it + grows the surface for no call-site benefit and pins the `Strict` / `Widening` + split. - **Exporting the `matcher-shared.ts` vocabulary** (`Matchable`, `UnaryFn`, `PatternKey`, `Member`, `PatternReturns`, …). They appear in the public signatures, but a consumer never needs to name them; exporting them would - freeze internals as API. + freeze plumbing as API. - **A hand-written `.d.ts` or a separate API document.** It would drift from the implementation; TSDoc is generated from the source. diff --git a/src/index.test.ts b/src/index.test.ts index ac45a15..861a941 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -8,18 +8,8 @@ import { getPrimitiveUnionMatcherW, getTaggedUnionMatcher, getTaggedUnionMatcherW, - type PrimitiveUnionMatcher, - type PrimitiveUnionMatcherW, - type TaggedUnionMatcher, - type TaggedUnionMatcherFactory, - type TaggedUnionMatcherW, - type TaggedUnionMatcherWFactory, } from "./index.ts"; -interface Shape { - readonly kind: "circle" | "square"; -} - // The published entry point is the barrel (`package.json` exports // `./dist/index.js`), so every factory must be reachable from here. Importing it // also loads the module, which is what lets c8's `--all` measure it — see @@ -35,42 +25,3 @@ test("index: the public entry point re-exports every matcher factory", () => { expectTypeOf(getTaggedUnionMatcherW).toBeFunction(); assert.equal(typeof getTaggedUnionMatcherW, "function"); }); - -// The builder types are part of the public contract: a consumer annotates a -// factory result with them, so the exported names must match the inferred -// return types. -test("index: the primitive-union factories return the exported builder types", () => { - // Act - const strict = getPrimitiveUnionMatcher<"a" | "b">(); - const widening = getPrimitiveUnionMatcherW<"a" | "b">(); - - // Assert - expectTypeOf(strict).toEqualTypeOf>(); - assert.equal(typeof strict, "function"); - expectTypeOf(widening).toEqualTypeOf>(); - assert.equal(typeof widening, "function"); -}); - -test("index: the tagged-union factories return the exported factory types", () => { - // Act - const strict = getTaggedUnionMatcher(); - const widening = getTaggedUnionMatcherW(); - - // Assert - expectTypeOf(strict).toEqualTypeOf>(); - assert.equal(typeof strict, "function"); - expectTypeOf(widening).toEqualTypeOf>(); - assert.equal(typeof widening, "function"); -}); - -test("index: the tagged-union key step returns the exported builder types", () => { - // Act - const strict = getTaggedUnionMatcher()("kind"); - const widening = getTaggedUnionMatcherW()("kind"); - - // Assert - expectTypeOf(strict).toEqualTypeOf>(); - assert.equal(typeof strict, "function"); - expectTypeOf(widening).toEqualTypeOf>(); - assert.equal(typeof widening, "function"); -}); diff --git a/src/index.ts b/src/index.ts index 62d22fd..be33df2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,8 @@ /** * The public entry point of `tiny-pattern-ts`. * - * Exports the four matcher factories and the builder types they return. - * Everything else in `src/` is an implementation detail. + * Exports the four matcher factories and nothing else; the builder types they + * return and the rest of `src/` are implementation detail. * * @module */ @@ -10,17 +10,7 @@ export { getPrimitiveUnionMatcher, getPrimitiveUnionMatcherW, } from "./primitive-union.ts"; -export type { - PrimitiveUnionMatcher, - PrimitiveUnionMatcherW, -} from "./primitive-union.ts"; export { getTaggedUnionMatcher, getTaggedUnionMatcherW, } from "./tagged-union.ts"; -export type { - TaggedUnionMatcher, - TaggedUnionMatcherFactory, - TaggedUnionMatcherW, - TaggedUnionMatcherWFactory, -} from "./tagged-union.ts"; diff --git a/src/primitive-union.ts b/src/primitive-union.ts index 34a95e9..4ef4a5f 100644 --- a/src/primitive-union.ts +++ b/src/primitive-union.ts @@ -44,14 +44,7 @@ type MustBePartial = // Strict returns: one common `R`. Overload order is load-bearing: // #1 Handlers (first) -> the exhaustive form and the autocomplete popup // #2 Fallback (last) -> accepts a partial handler map plus a fallback -/** - * The builder returned by {@link getPrimitiveUnionMatcher}: call it with a - * handler map for every member of `T` to get a strict matcher, or with a partial - * map plus a fallback. - * - * @typeParam T - The finite universe of primitive members to match. - */ -export interface PrimitiveUnionMatcher { +interface PrimitiveUnionMatcherStrict { (handlers: Handlers & UniverseGate): UnaryFn; < R, @@ -66,14 +59,7 @@ export interface PrimitiveUnionMatcher { // Widened returns: the union of every handler's return type. `P` is inferred // from the whole handler map, whose closed constraint supplies the // contextual/autocomplete type. -/** - * The builder returned by {@link getPrimitiveUnionMatcherW}: like - * {@link PrimitiveUnionMatcher}, but the result's return type is the union of - * every handler's return type instead of one common `R`. - * - * @typeParam T - The finite universe of primitive members to match. - */ -export interface PrimitiveUnionMatcherW { +interface PrimitiveUnionMatcherWidening {

, P>>( handlers: P & UniverseGate, ): UnaryFn>; @@ -130,7 +116,7 @@ const dispatch = */ export const getPrimitiveUnionMatcher = < T extends Matchable, ->(): PrimitiveUnionMatcher => dispatch; +>(): PrimitiveUnionMatcherStrict => dispatch; /** * Create a matcher for a finite primitive universe whose return type is the @@ -151,4 +137,4 @@ export const getPrimitiveUnionMatcher = < */ export const getPrimitiveUnionMatcherW = < T extends Matchable, ->(): PrimitiveUnionMatcherW => dispatch; +>(): PrimitiveUnionMatcherWidening => dispatch; diff --git a/src/tagged-union.ts b/src/tagged-union.ts index 58199b1..97fd2f6 100644 --- a/src/tagged-union.ts +++ b/src/tagged-union.ts @@ -87,15 +87,7 @@ type MustBePartial = // Strict returns: one common `R`. Overload order is load-bearing: // #1 Handlers (first) -> the exhaustive form and the autocomplete popup // #2 Fallback (last) -> accepts a partial handler map plus a fallback -/** - * The builder returned by the key step of {@link getTaggedUnionMatcher}: call it - * with a handler map for every tag under `K` to get a strict matcher, or with a - * partial map plus a fallback. - * - * @typeParam T - The discriminated-union type to match. - * @typeParam K - The discriminant property of `T`. - */ -export interface TaggedUnionMatcher { +interface TaggedUnionMatcherStrict { (handlers: Handlers & UniverseGate>): UnaryFn; < R, @@ -112,15 +104,7 @@ export interface TaggedUnionMatcher { // Widened returns: the union of every handler's return type. `P` is inferred // from the whole handler map, whose closed constraint supplies the // contextual/autocomplete type. -/** - * The builder returned by the key step of {@link getTaggedUnionMatcherW}: like - * {@link TaggedUnionMatcher}, but the result's return type is the union of every - * handler's return type instead of one common `R`. - * - * @typeParam T - The discriminated-union type to match. - * @typeParam K - The discriminant property of `T`. - */ -export interface TaggedUnionMatcherW { +interface TaggedUnionMatcherWidening {

, P>>( handlers: P & UniverseGate>, ): UnaryFn>; @@ -137,29 +121,15 @@ export interface TaggedUnionMatcherW { // The key-taking step of the curried factory. Naming it lets the factory return // `dispatch` directly, the tacit twin of the primitive-union factory's bare // `=> dispatch`. -/** - * The function returned by {@link getTaggedUnionMatcher}: call it with the - * discriminant property's name to get the handler-map builder. - * - * @typeParam T - The discriminated-union type to match. - */ -export type TaggedUnionMatcherFactory = < - K extends Discriminated, ->( +type TaggedUnionMatcherFactory = >( k: K, -) => TaggedUnionMatcher; +) => TaggedUnionMatcherStrict; -/** - * The function returned by {@link getTaggedUnionMatcherW}: call it with the - * discriminant property's name to get the widening handler-map builder. - * - * @typeParam T - The discriminated-union type to match. - */ -export type TaggedUnionMatcherWFactory = < +type TaggedUnionMatcherWideningFactory = < K extends Discriminated, >( k: K, -) => TaggedUnionMatcherW; +) => TaggedUnionMatcherWidening; const dispatch = (k: PropertyKey) => @@ -227,4 +197,4 @@ export const getTaggedUnionMatcher = < */ export const getTaggedUnionMatcherW = < T extends object, ->(): TaggedUnionMatcherWFactory => dispatch; +>(): TaggedUnionMatcherWideningFactory => dispatch; From c8bee95088e387ec51114b6169e8ae45201796de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Wed, 23 Sep 2026 22:25:39 +0000 Subject: [PATCH 4/7] :memo: Document when to use each matcher Add the universe/return axes and a "Use when" column to the README API table, and the same one-liner to each of the four factory JSDoc blocks. --- README.md | 24 ++++++++++++++---------- src/primitive-union.ts | 10 +++++++--- src/tagged-union.ts | 11 ++++++++--- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index ed02e3f..a7abf31 100644 --- a/README.md +++ b/README.md @@ -21,17 +21,21 @@ for the design decisions and [Caveats](#caveats) for the limits. ## API -The package exports four factories. Each comes in a _strict_ variant (one common -return type `R`) and a _widening_ variant: the `W` suffix means **widening** — -the return value is widened from one common `R` to the union of every handler's -return type. +The package exports four factories. Two axes pick one: -| Factory | Return of the matcher | -| -------------------------------- | -------------------------------- | -| `getPrimitiveUnionMatcher()` | one common `R` | -| `getPrimitiveUnionMatcherW()` | the union of the handler returns | -| `getTaggedUnionMatcher()` | one common `R` | -| `getTaggedUnionMatcherW()` | the union of the handler returns | +- **Universe** — a _primitive-union_ matcher matches a value that is itself a + finite union (`"yes" | "no"`); a _tagged-union_ matcher matches an object + discriminated by a property (`{ kind: … }`). +- **Return** — the _strict_ variant gives every handler one common return type + `R`; the _widening_ variant (`W`) widens the return value to the union of the + handler returns. + +| Factory | Use when | Return | +| -------------------------------- | ------------------------------------------------------------------------- | --------------------- | +| `getPrimitiveUnionMatcher()` | the value is the union and all handlers return the same type | one common `R` | +| `getPrimitiveUnionMatcherW()` | the value is the union and handlers return different types | union of the handlers | +| `getTaggedUnionMatcher()` | the value is a discriminated object and all handlers return the same type | one common `R` | +| `getTaggedUnionMatcherW()` | the value is a discriminated object and handlers return different types | union of the handlers | ### Primitive-union matchers diff --git a/src/primitive-union.ts b/src/primitive-union.ts index 4ef4a5f..622c0d9 100644 --- a/src/primitive-union.ts +++ b/src/primitive-union.ts @@ -99,6 +99,9 @@ const dispatch = /** * Create a matcher for a finite primitive universe, with one common return type. * + * Use it when the value itself is the union (`"yes" | "no"`) and every handler + * returns the same type. + * * The universe `T` must be a finite union of literals with no * value/stringification collision: broad members (`string`, `number`, template * literals) and `true | "true"` / `1 | "1"` are rejected at the call site. The @@ -122,9 +125,10 @@ export const getPrimitiveUnionMatcher = < * Create a matcher for a finite primitive universe whose return type is the * union of every handler's return type. * - * The widening counterpart of {@link getPrimitiveUnionMatcher}: use it when the - * handlers return different types and the union, not one common `R`, is wanted. - * The universe constraint and the optional fallback are identical. + * Use it when the value itself is the union (`"yes" | "no"`) and the handlers + * return different types. The `W` (widening) counterpart of + * {@link getPrimitiveUnionMatcher}; the universe constraint and the optional + * fallback are identical. * * @typeParam T - The finite universe of primitive members to match. * @returns A builder for the handler map, or the handler map plus a fallback. diff --git a/src/tagged-union.ts b/src/tagged-union.ts index 97fd2f6..7a18a29 100644 --- a/src/tagged-union.ts +++ b/src/tagged-union.ts @@ -155,6 +155,10 @@ const dispatch = /** * Create a matcher for a discriminated union, with one common return type. * + * Use it when the value is an object discriminated by a property + * (`{ kind: "circle" } | { kind: "square" }`) and every handler returns the + * same type. + * * The first call fixes the union `T`; the returned function takes the * discriminant property's name (`K`, restricted to properties whose values are * tags), and that returns the handler-map builder. Supplying a second fallback @@ -182,9 +186,10 @@ export const getTaggedUnionMatcher = < * Create a matcher for a discriminated union whose return type is the union of * every handler's return type. * - * The widening counterpart of {@link getTaggedUnionMatcher}: use it when the - * handlers return different types and the union, not one common `R`, is wanted. - * The curried key step and the optional fallback are identical. + * Use it when the value is a discriminated object and the handlers return + * different types. The `W` (widening) counterpart of + * {@link getTaggedUnionMatcher}; the curried key step and the optional fallback + * are identical. * * @typeParam T - The discriminated-union type to match. * @returns A function that takes the discriminant property's name. From c878e60ff24dff7b6a9b948ba72acfc515311b45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Wed, 23 Sep 2026 22:33:43 +0000 Subject: [PATCH 5/7] :memo: Bind the handler-map function in every example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each README and JSDoc example now assigns the function that takes the handler object (the factory result, or the tagged key step) to a match… variable and reuses it, so the builder is created once instead of per call. --- README.md | 15 ++++++++++++--- src/primitive-union.ts | 6 ++++-- src/tagged-union.ts | 6 ++++-- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a7abf31..6042d20 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,9 @@ The package exports four factories. Two axes pick one: | `getTaggedUnionMatcher()` | the value is a discriminated object and all handlers return the same type | one common `R` | | `getTaggedUnionMatcherW()` | the value is a discriminated object and handlers return different types | union of the handlers | +Bind the function that takes the handler map to a `match…` variable once and +reuse it; the examples below do this, so the builder is allocated once. + ### Primitive-union matchers `getPrimitiveUnionMatcher()` takes the finite universe `T` and returns a @@ -46,7 +49,9 @@ matcher: a function from `T` to the common return type. ```ts import { getPrimitiveUnionMatcher } from "tiny-pattern-ts"; -const reply = getPrimitiveUnionMatcher<"yes" | "no">()({ +const matchAnswer = getPrimitiveUnionMatcher<"yes" | "no">(); + +const reply = matchAnswer({ yes: () => "agreed", no: () => "declined", }); @@ -58,7 +63,9 @@ Add a fallback as the second argument to leave members unhandled; the fallback receives the remainder: ```ts -const label = getPrimitiveUnionMatcher<"yes" | "no" | "maybe">()( +const matchLabel = getPrimitiveUnionMatcher<"yes" | "no" | "maybe">(); + +const label = matchLabel( { yes: () => "agreed", no: () => "declined" }, (other) => `not sure: ${other}`, // other: "maybe" ); @@ -79,7 +86,9 @@ import { getTaggedUnionMatcher } from "tiny-pattern-ts"; type Shape = { kind: "circle"; radius: number } | { kind: "square"; side: number }; -const area = getTaggedUnionMatcher()("kind")({ +const matchShape = getTaggedUnionMatcher()("kind"); + +const area = matchShape({ circle: (s) => Math.PI * s.radius ** 2, square: (s) => s.side ** 2, }); diff --git a/src/primitive-union.ts b/src/primitive-union.ts index 622c0d9..3d988d1 100644 --- a/src/primitive-union.ts +++ b/src/primitive-union.ts @@ -111,7 +111,8 @@ const dispatch = * @typeParam T - The finite universe of primitive members to match. * @returns A builder for the handler map, or the handler map plus a fallback. * @example - * const describe = getPrimitiveUnionMatcher<"yes" | "no">()({ + * const matchAnswer = getPrimitiveUnionMatcher<"yes" | "no">(); + * const describe = matchAnswer({ * yes: () => "agreed", * no: () => "declined", * }); @@ -133,7 +134,8 @@ export const getPrimitiveUnionMatcher = < * @typeParam T - The finite universe of primitive members to match. * @returns A builder for the handler map, or the handler map plus a fallback. * @example - * const reply = getPrimitiveUnionMatcherW<"yes" | "no">()({ + * const matchReply = getPrimitiveUnionMatcherW<"yes" | "no">(); + * const reply = matchReply({ * yes: () => 1, * no: () => "declined", * }); diff --git a/src/tagged-union.ts b/src/tagged-union.ts index 7a18a29..d3a2acd 100644 --- a/src/tagged-union.ts +++ b/src/tagged-union.ts @@ -173,7 +173,8 @@ const dispatch = * | { kind: "circle"; radius: number } * | { kind: "square"; side: number }; * - * const area = getTaggedUnionMatcher()("kind")({ + * const matchShape = getTaggedUnionMatcher()("kind"); + * const area = matchShape({ * circle: (s) => Math.PI * s.radius ** 2, * square: (s) => s.side ** 2, * }); @@ -194,7 +195,8 @@ export const getTaggedUnionMatcher = < * @typeParam T - The discriminated-union type to match. * @returns A function that takes the discriminant property's name. * @example - * const describe = getTaggedUnionMatcherW()("kind")({ + * const matchShape = getTaggedUnionMatcherW()("kind"); + * const describe = matchShape({ * circle: () => "round", * square: () => 4, * }); From 38484aa16dfeb5c7c61430f3ddc78eaa08d1e9d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Wed, 23 Sep 2026 22:50:05 +0000 Subject: [PATCH 6/7] :memo: Refresh license year, backlog notes and JSDoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump the copyright to 2026 in LICENSE and README, add the comparison-section notes to the backlog, and trim the primitive-union factory JSDoc now that the universe rules live in README § Caveats. --- LICENSE | 2 +- README.md | 2 +- backlog.tasks | 2 ++ src/primitive-union.ts | 11 +++++------ 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/LICENSE b/LICENSE index 01f2d94..f5ee8b1 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 tmu +Copyright (c) 2026 tmu Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 6042d20..ee62d7a 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ cover. The type-level cost of supporting open universes is recorded in ## License -MIT © 2025 tmu. See [LICENSE](./LICENSE). +MIT © 2026 tmu. See [LICENSE](./LICENSE). ## Contributing diff --git a/backlog.tasks b/backlog.tasks index 9d32d75..4eb0e05 100644 --- a/backlog.tasks +++ b/backlog.tasks @@ -28,6 +28,8 @@ Documentation: → previous Examples order: literal/exhaustive, typeof, structural/discriminated unions, when, any ☐ Create `examples/` directory with runnable snippets ☐ Add comparison section vs. other TS pattern-matching libs in Readme.md + ☐ Why do we do this? => exhaustiveness encoded type safe + ☐ Why this form? little syntax, data last, very small, autocomplete, strict typing in the handler; for more features use ts-pattern ☐ Write migration guide for users coming from discriminated unions ☐ Create backlog tasks for implementation ☐ Validate code fences in Markdown (start with README.md) — compile the TypeScript examples against `src/` so the docs cannot drift from the API diff --git a/src/primitive-union.ts b/src/primitive-union.ts index 3d988d1..da6a705 100644 --- a/src/primitive-union.ts +++ b/src/primitive-union.ts @@ -97,16 +97,15 @@ const dispatch = ); /** - * Create a matcher for a finite primitive universe, with one common return type. + * Create a matcher for a finite primitive universe, with one common return + * type. * * Use it when the value itself is the union (`"yes" | "no"`) and every handler * returns the same type. * - * The universe `T` must be a finite union of literals with no - * value/stringification collision: broad members (`string`, `number`, template - * literals) and `true | "true"` / `1 | "1"` are rejected at the call site. The - * returned builder takes a handler map keyed by the members; supplying a second - * fallback argument allows a partial map and receives the unhandled remainder. + * The returned builder takes a handler map keyed by the members; supplying a + * second fallback argument allows a partial map and receives the unhandled + * remainder. * * @typeParam T - The finite universe of primitive members to match. * @returns A builder for the handler map, or the handler map plus a fallback. From 0bde38d6e4a07e76806b65c1d3cf58dc1e686539 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Wed, 23 Sep 2026 22:51:11 +0000 Subject: [PATCH 7/7] :memo: Summarize the API docs work in the changelog --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa2779d..d1278c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- document the public API in the README: the four factories, when to use each, + what the `W` (widening) suffix means, and examples that bind the handler-map + function once - add TSDoc to the four public matcher factories -- document the public API in the README ## [0.8.1] - 2026-09-23