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] :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;