✨ Finalize and document the public API surface

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.
This commit is contained in:
tmu committed 2026-09-23 21:39:15 +00:00
1 parent acf9d06ddd
commit f485b1bae2
8 files changed
+317 -20

No files matched your search

+6
View File
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ## [0.8.1] - 2026-09-23
- gate CI at 100% coverage: `test:ci` runs c8 with `--all --100` over `src/`, - gate CI at 100% coverage: `test:ci` runs c8 with `--all --100` over `src/`,
+73 -1
View File
@@ -21,7 +21,79 @@ for the design decisions and [Caveats](#caveats) for the limits.
## API ## 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<T>()` | `PrimitiveUnionMatcher<T>` | one common `R` |
| `getPrimitiveUnionMatcherW<T>()` | `PrimitiveUnionMatcherW<T>` | the union of the handler returns |
| `getTaggedUnionMatcher<T>()` | `TaggedUnionMatcherFactory<T>` | one common `R` |
| `getTaggedUnionMatcherW<T>()` | `TaggedUnionMatcherWFactory<T>` | the union of the handler returns |
### Primitive-union matchers
`getPrimitiveUnionMatcher<T>()` 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<T>()` 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<Shape>()("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 ## Caveats
+4 -4
View File
@@ -8,10 +8,10 @@ Setup:
☐ Split off template into separate package => pi --session 01a07dde-7050-7054-bb36-1606d7eb2bc3 @low ☐ Split off template into separate package => pi --session 01a07dde-7050-7054-bb36-1606d7eb2bc3 @low
v1.0: v1.0:
☐ API surface is stable and fully typed ✔ API surface is stable and fully typed @done
☐ Finalize public exports in `src/index.ts` ✔ Finalize public exports in `src/index.ts` @done
☐ Document all exported types and functions ✔ Document all exported types and functions @done
☐ Add JSDoc for public APIs ✔ Add JSDoc for public APIs @done
✔ Test coverage meets threshold @done ✔ Test coverage meets threshold @done
✔ Achieve 100% branch coverage on `src/primitive-union.ts` @done ✔ Achieve 100% branch coverage on `src/primitive-union.ts` @done
✔ Achieve 100% branch coverage on `src/index.ts` @done ✔ Achieve 100% branch coverage on `src/index.ts` @done
+35 -2
View File
@@ -5,8 +5,41 @@ user-facing reference is [README § API](../README.md#api).
The matchers are implemented in `src/primitive-union.ts` (`getPrimitiveUnionMatcher` / The matchers are implemented in `src/primitive-union.ts` (`getPrimitiveUnionMatcher` /
`getPrimitiveUnionMatcherW`) and `src/tagged-union.ts` (`getTaggedUnionMatcher` / `getPrimitiveUnionMatcherW`) and `src/tagged-union.ts` (`getTaggedUnionMatcher` /
`getTaggedUnionMatcherW`), re-exported from `src/index.ts`; the rest of the `getTaggedUnionMatcherW`), re-exported from `src/index.ts`; the rest of `src/`
library is placeholder code. 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 ## Matcher shape
+49
View File
@@ -8,8 +8,18 @@ import {
getPrimitiveUnionMatcherW, getPrimitiveUnionMatcherW,
getTaggedUnionMatcher, getTaggedUnionMatcher,
getTaggedUnionMatcherW, getTaggedUnionMatcherW,
type PrimitiveUnionMatcher,
type PrimitiveUnionMatcherW,
type TaggedUnionMatcher,
type TaggedUnionMatcherFactory,
type TaggedUnionMatcherW,
type TaggedUnionMatcherWFactory,
} from "./index.ts"; } from "./index.ts";
interface Shape {
readonly kind: "circle" | "square";
}
// The published entry point is the barrel (`package.json` exports // The published entry point is the barrel (`package.json` exports
// `./dist/index.js`), so every factory must be reachable from here. Importing it // `./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 // 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(); expectTypeOf(getTaggedUnionMatcherW).toBeFunction();
assert.equal(typeof getTaggedUnionMatcherW, "function"); 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<PrimitiveUnionMatcher<"a" | "b">>();
assert.equal(typeof strict, "function");
expectTypeOf(widening).toEqualTypeOf<PrimitiveUnionMatcherW<"a" | "b">>();
assert.equal(typeof widening, "function");
});
test("index: the tagged-union factories return the exported factory types", () => {
// Act
const strict = getTaggedUnionMatcher<Shape>();
const widening = getTaggedUnionMatcherW<Shape>();
// Assert
expectTypeOf(strict).toEqualTypeOf<TaggedUnionMatcherFactory<Shape>>();
assert.equal(typeof strict, "function");
expectTypeOf(widening).toEqualTypeOf<TaggedUnionMatcherWFactory<Shape>>();
assert.equal(typeof widening, "function");
});
test("index: the tagged-union key step returns the exported builder types", () => {
// Act
const strict = getTaggedUnionMatcher<Shape>()("kind");
const widening = getTaggedUnionMatcherW<Shape>()("kind");
// Assert
expectTypeOf(strict).toEqualTypeOf<TaggedUnionMatcher<Shape, "kind">>();
assert.equal(typeof strict, "function");
expectTypeOf(widening).toEqualTypeOf<TaggedUnionMatcherW<Shape, "kind">>();
assert.equal(typeof widening, "function");
});
+18
View File
@@ -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 { export {
getPrimitiveUnionMatcher, getPrimitiveUnionMatcher,
getPrimitiveUnionMatcherW, getPrimitiveUnionMatcherW,
} from "./primitive-union.ts"; } from "./primitive-union.ts";
export type {
PrimitiveUnionMatcher,
PrimitiveUnionMatcherW,
} from "./primitive-union.ts";
export { export {
getTaggedUnionMatcher, getTaggedUnionMatcher,
getTaggedUnionMatcherW, getTaggedUnionMatcherW,
} from "./tagged-union.ts"; } from "./tagged-union.ts";
export type {
TaggedUnionMatcher,
TaggedUnionMatcherFactory,
TaggedUnionMatcherW,
TaggedUnionMatcherWFactory,
} from "./tagged-union.ts";
+54 -4
View File
@@ -44,7 +44,14 @@ type MustBePartial<T extends Matchable, Handled> =
// Strict returns: one common `R`. Overload order is load-bearing: // Strict returns: one common `R`. Overload order is load-bearing:
// #1 Handlers (first) -> the exhaustive form and the autocomplete popup // #1 Handlers (first) -> the exhaustive form and the autocomplete popup
// #2 Fallback (last) -> accepts a partial handler map plus a fallback // #2 Fallback (last) -> accepts a partial handler map plus a fallback
interface PrimitiveUnionMatcherStrict<T extends Matchable> { /**
* 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<T extends Matchable> {
<R>(handlers: Handlers<T, R> & UniverseGate<T>): UnaryFn<T, R>; <R>(handlers: Handlers<T, R> & UniverseGate<T>): UnaryFn<T, R>;
< <
R, R,
@@ -59,7 +66,14 @@ interface PrimitiveUnionMatcherStrict<T extends Matchable> {
// Widened returns: the union of every handler's return type. `P` is inferred // Widened returns: the union of every handler's return type. `P` is inferred
// from the whole handler map, whose closed constraint supplies the // from the whole handler map, whose closed constraint supplies the
// contextual/autocomplete type. // contextual/autocomplete type.
interface PrimitiveUnionMatcherWidening<T extends Matchable> { /**
* 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<T extends Matchable> {
<P extends Exact<Handlers<T, unknown>, P>>( <P extends Exact<Handlers<T, unknown>, P>>(
handlers: P & UniverseGate<T>, handlers: P & UniverseGate<T>,
): UnaryFn<T, PatternReturns<P>>; ): UnaryFn<T, PatternReturns<P>>;
@@ -96,9 +110,45 @@ const dispatch =
shape as never, 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 = < export const getPrimitiveUnionMatcher = <
T extends Matchable, T extends Matchable,
>(): PrimitiveUnionMatcherStrict<T> => dispatch; >(): PrimitiveUnionMatcher<T> => 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 = < export const getPrimitiveUnionMatcherW = <
T extends Matchable, T extends Matchable,
>(): PrimitiveUnionMatcherWidening<T> => dispatch; >(): PrimitiveUnionMatcherW<T> => dispatch;
+78 -9
View File
@@ -87,7 +87,15 @@ type MustBePartial<T extends object, K extends keyof T, Handled> =
// Strict returns: one common `R`. Overload order is load-bearing: // Strict returns: one common `R`. Overload order is load-bearing:
// #1 Handlers (first) -> the exhaustive form and the autocomplete popup // #1 Handlers (first) -> the exhaustive form and the autocomplete popup
// #2 Fallback (last) -> accepts a partial handler map plus a fallback // #2 Fallback (last) -> accepts a partial handler map plus a fallback
interface TaggedUnionMatcherStrict<T extends object, K extends keyof T> { /**
* 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<T extends object, K extends keyof T> {
<R>(handlers: Handlers<T, K, R> & UniverseGate<Tags<T, K>>): UnaryFn<T, R>; <R>(handlers: Handlers<T, K, R> & UniverseGate<Tags<T, K>>): UnaryFn<T, R>;
< <
R, R,
@@ -104,7 +112,15 @@ interface TaggedUnionMatcherStrict<T extends object, K extends keyof T> {
// Widened returns: the union of every handler's return type. `P` is inferred // Widened returns: the union of every handler's return type. `P` is inferred
// from the whole handler map, whose closed constraint supplies the // from the whole handler map, whose closed constraint supplies the
// contextual/autocomplete type. // contextual/autocomplete type.
interface TaggedUnionMatcherWidening<T extends object, K extends keyof T> { /**
* 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<T extends object, K extends keyof T> {
<P extends Exact<Handlers<T, K, unknown>, P>>( <P extends Exact<Handlers<T, K, unknown>, P>>(
handlers: P & UniverseGate<Tags<T, K>>, handlers: P & UniverseGate<Tags<T, K>>,
): UnaryFn<T, PatternReturns<P>>; ): UnaryFn<T, PatternReturns<P>>;
@@ -121,15 +137,29 @@ interface TaggedUnionMatcherWidening<T extends object, K extends keyof T> {
// The key-taking step of the curried factory. Naming it lets the factory return // 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` directly, the tacit twin of the primitive-union factory's bare
// `=> dispatch`. // `=> dispatch`.
type TaggedUnionMatcherFactory<T extends object> = <K extends Discriminated<T>>( /**
k: K, * The function returned by {@link getTaggedUnionMatcher}: call it with the
) => TaggedUnionMatcherStrict<T, K>; * discriminant property's name to get the handler-map builder.
*
type TaggedUnionMatcherWideningFactory<T extends object> = < * @typeParam T - The discriminated-union type to match.
*/
export type TaggedUnionMatcherFactory<T extends object> = <
K extends Discriminated<T>, K extends Discriminated<T>,
>( >(
k: K, k: K,
) => TaggedUnionMatcherWidening<T, K>; ) => TaggedUnionMatcher<T, K>;
/**
* 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<T extends object> = <
K extends Discriminated<T>,
>(
k: K,
) => TaggedUnionMatcherW<T, K>;
const dispatch = const dispatch =
(k: PropertyKey) => (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<Shape>()("kind")({
* circle: (s) => Math.PI * s.radius ** 2,
* square: (s) => s.side ** 2,
* });
*/
export const getTaggedUnionMatcher = < export const getTaggedUnionMatcher = <
T extends object, T extends object,
>(): TaggedUnionMatcherFactory<T> => dispatch; >(): TaggedUnionMatcherFactory<T> => 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<Shape>()("kind")({
* circle: () => "round",
* square: () => 4,
* });
* // describe: (shape: Shape) => string | number
*/
export const getTaggedUnionMatcherW = < export const getTaggedUnionMatcherW = <
T extends object, T extends object,
>(): TaggedUnionMatcherWideningFactory<T> => dispatch; >(): TaggedUnionMatcherWFactory<T> => dispatch;