From 28d87b3387cb6ecee041f9eb8b82b05a33b59cdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Thu, 24 Sep 2026 20:51:42 +0000 Subject: [PATCH 1/2] :memo: Close out migration guide and backlog tasks --- backlog.tasks | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backlog.tasks b/backlog.tasks index a30cf58..257c79d 100644 --- a/backlog.tasks +++ b/backlog.tasks @@ -30,8 +30,8 @@ Documentation: ☐ 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 +✔ Write migration guide for users coming from discriminated unions @done (9/24/2026, 10:21:17 PM) +✔ Create backlog tasks for implementation @done (9/24/2026, 10:21:16 PM) ✔ Validate code fences in Markdown (start with README.md) — compile the TypeScript examples against `src/` so the docs cannot drift from the API @done Maintenance: From 826175919031875de49cccad27cdeb084cefa77e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Thu, 24 Sep 2026 20:54:18 +0000 Subject: [PATCH 2/2] :memo: Restore and rewrite the README entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README collapsed to API-only prose once the old match/P surface was dropped. Rebuild its entry-point shape: a quick-start Synopsis walked through a Contact union, a dedicated Installation section, and a real-world Examples section (primitive-union dispatch, a fallback, a property union narrowed by the tagged-union matcher, and the widening variant). Set the tagline and Description to the library's identity — exhaustive, type-safe pattern matching for TypeScript — and state the goal Moose-style: type-safe pattern matching with a lean syntax, accomplished by exhaustive branches and typed per-branch handler parameters, backed by autocomplete and a tiny footprint. Drop the F#-style framing and the stale "matches method is a type guard" and "(not regex)" copy from the README, AGENTS.md and the package.json description. Every fence is a doc-test, so the examples cannot drift from the API. --- AGENTS.md | 2 +- CHANGELOG.md | 3 + README.md | 148 +++++++++++++++++++++++++++++++++++++++++++++++--- backlog.tasks | 2 +- package.json | 2 +- 5 files changed, 146 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3ca0553..ad46ad2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ first-action facts. Do not restate evolving prose here — it will drift. ## First action -- Project: F#-style pattern matching for TypeScript/ESM. Node `>=26` (pinned via `.node-version`), ESM-only (no CommonJS shim). +- Project: pattern matching for TypeScript/ESM. Node `>=26` (pinned via `.node-version`), ESM-only (no CommonJS shim). - **While iterating:** `npm run test` (`check:tsc` + the unit suite) for fast feedback on the files you changed. - **Optional code intelligence:** this repo installs `@spences10/pi-lsp` (pinned in `.pi/settings.json`) as a project-local pi extension. It talks to the repo's own TypeScript 7 via `tsc --lsp --stdio` and exposes **read-only** tools — `lsp_hover`, `lsp_definition`, `lsp_references`, `lsp_find_symbol`, `lsp_document_symbols`, `lsp_diagnostics(_many)`. **When you are looking for a symbol, reach for the LSP before `rg`/`grep`** — `lsp_references` / `lsp_find_symbol` / `lsp_definition` / `lsp_document_symbols` are semantic and cross-file, so they see shadowing, imports and overloads that a text search cannot; use `lsp_hover` to read inferred types on generic-heavy code. Use `rg` for what the LSP cannot see — doc prose, string literals, config, task lists, file discovery — and reconcile the two sets before editing (symbols from the LSP, strings and prose from `rg`). It has no rename / code-action / apply-edit surface — the write side is pi's `edit` tool + `check:tsc`. Treat empty LSP output as _inconclusive_, not success: **`npm run test` / `npm run verify` remain the sole authoritative gate** (see the next bullet). The server keeps running across that gate with a ~5 min idle timeout and registers no file watchers, so if you change `tsconfig.json` / `package.json` mid-session its diagnostics can be stale — when LSP output disagrees with `check:tsc`, trust `check:tsc` and restart pi (or wait out the idle timeout) before concluding the LSP is wrong. - **Definition of done — run this before you call the work finished:** `npm run verify`. If all green, commit. If red, look at the output, fix the root cause, and re-run. diff --git a/CHANGELOG.md b/CHANGELOG.md index d1278c8..f7390d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- restore the README's Synopsis and Examples sections: a quick-start recipe plus + real-world examples (primitive-union dispatch, a fallback, a property union + narrowed by the tagged-union matcher, and a widening variant) - 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 diff --git a/README.md b/README.md index 6b3dc49..613224b 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,53 @@ # tiny-pattern-ts -Pattern matching for TypeScript/ESM environments (F#-style, not regex). +Exhaustive, type-safe pattern matching for TypeScript. + +## Synopsis + +```ts +import { getTaggedUnionMatcher } from "tiny-pattern-ts"; + +// 1. We have a union type +type Contact = + | { kind: "email"; address: string } + | { kind: "phone"; number: string } + | { kind: "messenger"; username: string }; + +// 2. Create a matcher providing the discriminant property +const matchContact = getTaggedUnionMatcher()("kind"); + +// 3. Define handlers for each branch of the union +const formatContact = matchContact({ + email: (e) => `MAIL: ${e.address}`, + phone: (p) => `PHONE: ${p.number}`, + messenger: (m) => `MESSENGER: @${m.username}`, +}); + +// 4. Call the matcher with a value +const mailOutput = formatContact({ kind: "email", address: "ada@example.com" }); +assert.equal(mailOutput, "MAIL: ada@example.com"); + +const phoneOutput = formatContact({ kind: "phone", number: "+1 555 0100" }); +assert.equal(phoneOutput, "PHONE: +1 555 0100"); +``` ## Description -`tiny-pattern-ts` brings F#-style pattern matching to TypeScript. Patterns are -ordinary objects whose `matches` method is a TypeScript type guard, so narrowing -composes the way any other guard does. It is deliberately not a regex engine and -not a macro: there is no transpiler and no DSL to learn, and the type-level -contract is the feature — see [development/library.md](./development/library.md) -for the design decisions and [Caveats](#caveats) for the limits. +`tiny-pattern-ts` is a pattern-matching library for TypeScript. + +The main goal of `tiny-pattern-ts` is to make pattern matching type-safe with a +lean syntax. This is accomplished by being exhaustive and passing typed +parameters per branch to the handlers — supported by an outstanding +autocomplete and a tiny footprint. + +See [development/library.md](./development/library.md) for the design decisions +and [Caveats](#caveats) for the limits. + +## Installation + +```sh +npm install tiny-pattern-ts +``` ## Requirements @@ -19,6 +57,100 @@ for the design decisions and [Caveats](#caveats) for the limits. both resolve on TS >= 5.0 in `node10` / `node16` / `nodenext` / `bundler`. - The package is **ESM-only** (no CommonJS shim). +## Examples + +A few real-world recipes. Each binds the handler-map function once and reuses +it, so the matcher is allocated a single time. + +### Dispatch on a primitive union + +A result code is itself a finite union, so `getPrimitiveUnionMatcher` keys a +handler on each member: + +```ts +import { getPrimitiveUnionMatcher } from "tiny-pattern-ts"; + +type ResultCode = "ok" | "created" | "no-content"; + +const toStatus = getPrimitiveUnionMatcher()({ + ok: () => 200, + created: () => 201, + "no-content": () => 204, +}); + +assert.equal(toStatus("ok"), 200); +assert.equal(toStatus("created"), 201); +assert.equal(toStatus("no-content"), 204); +``` + +### Leave cases to a fallback + +Pass a fallback as the second argument to handle only part of the universe; it +receives the members the map leaves uncovered — here the parameter is +`"deprecated" | "gateway-timeout"`: + +```ts +import { getPrimitiveUnionMatcher } from "tiny-pattern-ts"; + +type Status = "active" | "beta" | "deprecated" | "gateway-timeout"; + +const rollout = getPrimitiveUnionMatcher()( + { active: () => "enabled", beta: () => "enabled" }, + (status) => `blocked (${status})`, +); + +assert.equal(rollout("active"), "enabled"); +assert.equal(rollout("deprecated"), "blocked (deprecated)"); +``` + +### Dispatch on a property union + +The value does not have to be the union itself. When a single property carries a +finite union, the tagged-union matcher keys on it and narrows the whole record to +the selected value: + +```ts +import { getTaggedUnionMatcher } from "tiny-pattern-ts"; + +interface Invoice { + readonly currency: "eur" | "usd" | "jpy"; + readonly amount: number; +} + +const matchCurrency = getTaggedUnionMatcher()("currency"); + +const symbolOf = matchCurrency({ + eur: (i) => `€${i.amount.toFixed(2)}`, + usd: (i) => `$${i.amount.toFixed(2)}`, + jpy: (i) => `¥${i.amount.toFixed(0)}`, +}); + +assert.equal(symbolOf({ currency: "usd", amount: 12.5 }), "$12.50"); +assert.equal(symbolOf({ currency: "jpy", amount: 900 }), "¥900"); +``` + +### Widen the return type + +When the handlers return different types, reach for the widening `W` variant: the +matcher's return is their union rather than one common type — here +`string | string[] | undefined`: + +```ts +import { getPrimitiveUnionMatcherW } from "tiny-pattern-ts"; + +type Field = "name" | "tags" | "note"; + +const parse = getPrimitiveUnionMatcherW()({ + name: () => "Ada", + tags: () => ["admin", "beta"], + note: () => undefined, +}); + +assert.equal(parse("name"), "Ada"); +assert.deepEqual(parse("tags"), ["admin", "beta"]); +assert.equal(parse("note"), undefined); +``` + ## API The package exports four factories. Two axes pick one: @@ -38,7 +170,7 @@ The package exports four factories. Two axes pick one: | `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. +reuse it; the [Examples](#examples) do this, so the builder is allocated once. ### Primitive-union matchers diff --git a/backlog.tasks b/backlog.tasks index 257c79d..c91945d 100644 --- a/backlog.tasks +++ b/backlog.tasks @@ -23,7 +23,7 @@ Matcher: ✔ optional discriminant (`{ type?: "x" }`) is the same hole: the boolean/nullish change now admits the `undefined` tag, so the factory accepts the key, but `Extract>` still passes `never` to both the `x` and `undefined` handlers @done Documentation: -☐ Bring README.md back to its previous form — synopsis and examples restored, in the correct place +✔ Bring README.md back to its previous form — synopsis and examples restored, in the correct place @done → previous section order: title, tagline, Synopsis, Description, Requirements, Examples, API, License, Contributing → previous Examples order: literal/exhaustive, typeof, structural/discriminated unions, when, any ☐ Create `examples/` directory with runnable snippets diff --git a/package.json b/package.json index ea082e8..1ef108b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "tiny-pattern-ts", "version": "0.8.1", - "description": "Pattern matching for TypeScript/ESM environments (F#-style, not regex)", + "description": "Exhaustive, type-safe pattern matching for TypeScript", "keywords": [ "adt", "algebraic-data-types",