6 Commits
Author SHA1 Message Date
tmu ac1fd60043 🚀 Release 0.8.2
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 37s
CI / maintain (push) Failing after 15s
CI / publish (push) Failing after 15s
2026-09-25 23:20:02 +00:00
tmu f5871c37c8 🔀 Merge chore/readme-comparison into main 2026-09-25 23:17:33 +00:00
tmu 7a1eaa0d7f 📝 Add an alternatives section
Add a short README section under Alternatives, one heading per
alternative (ts-pattern, Effect.Match, match-iz and plain switch), each
saying what it is and what tiny-pattern-ts is or does differently. State
the data-last, pipe-friendly shape in Description.

- measure the footprint: ~0.2 kB minified + gzipped, not the
  unminified ~1.8 kB; ts-pattern is ~2 kB
- correct match-iz: it ships types, it just cannot prove exhaustiveness
- drop switchcase / ts-match (unmaintained)
- check off the backlog task

Resolves: backlog "Add comparison section vs. other TS pattern-matching libs"
2026-09-25 23:16:14 +00:00
tmu 09011d838f 🔀 Merge chore/cleanup-readme into main
CI / release-gate (push) Successful in 3s
CI / build (push) Successful in 27s
CI / publish (push) Skipped
CI / maintain (push) Failing after 15s
2026-09-24 21:56:33 +00:00
tmu 8261759190 📝 Restore and rewrite the README entry point
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.
2026-09-24 21:55:51 +00:00
tmu 28d87b3387 📝 Close out migration guide and backlog tasks 2026-09-24 20:51:42 +00:00
7 changed files with 199 additions and 24 deletions

No files matched your search

+1 -1
View File
@@ -7,7 +7,7 @@ first-action facts. Do not restate evolving prose here — it will drift.
## First action ## 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. - **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. - **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. - **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.
+7 -4
View File
@@ -7,10 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
- document the public API in the README: the four factories, when to use each, ## [0.8.2] - 2026-09-25
what the `W` (widening) suffix means, and examples that bind the handler-map
function once - write the README's Synopsis and Examples sections
- document the public API in the README
- add TSDoc to the four public matcher factories - add TSDoc to the four public matcher factories
- document alternatives
## [0.8.1] - 2026-09-23 ## [0.8.1] - 2026-09-23
@@ -106,7 +108,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- basic setup - basic setup
[Unreleased]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.8.1...main [Unreleased]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.8.2...main
[0.8.2]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.8.1...0.8.2
[0.8.1]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.8.0...0.8.1 [0.8.1]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.8.0...0.8.1
[0.8.0]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.7.1...0.8.0 [0.8.0]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.7.1...0.8.0
[0.7.1]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.7.0...0.7.1 [0.7.1]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.7.0...0.7.1
+179 -8
View File
@@ -1,15 +1,55 @@
# tiny-pattern-ts # 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<Contact>()("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 ## Description
`tiny-pattern-ts` brings F#-style pattern matching to TypeScript. Patterns are `tiny-pattern-ts` is a pattern-matching library for TypeScript.
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 The main goal of `tiny-pattern-ts` is to make pattern matching type-safe with a
not a macro: there is no transpiler and no DSL to learn, and the type-level lean syntax. This is accomplished by being exhaustive and passing typed
contract is the feature — see [development/library.md](./development/library.md) parameters per branch to the handlers — supported by an outstanding
for the design decisions and [Caveats](#caveats) for the limits. autocomplete and a tiny footprint. The matchers are data last and pipe-friendly:
build the handler map once, then apply the resulting matcher to values
(`match(value)`, or `pipe(value, match)`).
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 ## Requirements
@@ -19,6 +59,100 @@ for the design decisions and [Caveats](#caveats) for the limits.
both resolve on TS >= 5.0 in `node10` / `node16` / `nodenext` / `bundler`. both resolve on TS >= 5.0 in `node10` / `node16` / `nodenext` / `bundler`.
- The package is **ESM-only** (no CommonJS shim). - 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<ResultCode>()({
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<Status>()(
{ 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<Invoice>()("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<Field>()({
name: () => "Ada",
tags: () => ["admin", "beta"],
note: () => undefined,
});
assert.equal(parse("name"), "Ada");
assert.deepEqual(parse("tags"), ["admin", "beta"]);
assert.equal(parse("note"), undefined);
```
## API ## API
The package exports four factories. Two axes pick one: The package exports four factories. Two axes pick one:
@@ -38,7 +172,7 @@ The package exports four factories. Two axes pick one:
| `getTaggedUnionMatcherW<T>()` | the value is a discriminated object and handlers return different types | union of the handlers | | `getTaggedUnionMatcherW<T>()` | 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 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 ### Primitive-union matchers
@@ -152,6 +286,43 @@ provable exhaustiveness — to automate what a `switch` and a default arm alread
cover. The type-level cost of supporting open universes is recorded in cover. The type-level cost of supporting open universes is recorded in
[development/library.md](./development/library.md#supported-universes). [development/library.md](./development/library.md#supported-universes).
## Alternatives
### [`ts-pattern`](https://github.com/gvergnaud/ts-pattern)
- is the full structural matcher — nested and partial patterns, guards, unions,
captures — exhausting at `.exhaustive()`
- has a fluent `.with(…)` chain that is heavy on syntax; tiny-pattern-ts is one
handler map
- is about 2 kB minified and gzipped; tiny-pattern-ts is 0.2 kB
- reach for it when you need a feature tiny-pattern-ts does not cover
### [Effect's `Match`](https://effect.website/docs/code-style/pattern-matching/)
- is the same piped matcher (`Match.type` / `Match.when` / `Match.exhaustive`),
but only as part of the `effect` ecosystem
- tiny-pattern-ts is standalone: no runtime dependency to buy into
### [`match-iz`](https://github.com/shuckster/match-iz)
- expresses patterns in the TC39 proposal's style, deciding each case at runtime
- is written in JavaScript with hand-maintained declarations, so its types do
not prove the cases exhaustive
- tiny-pattern-ts does: exhaustiveness is a compile-time guarantee, not an
`otherwise` fallback
### plain `switch` (baseline)
- is the zero-dependency baseline — pair it with
[`eslint-plugin-strict-pattern-matching`](https://www.npmjs.com/package/eslint-plugin-strict-pattern-matching)
for exhaustiveness
- is a statement, not an expression, so it cannot produce a value directly
- leaves the `never` guard to you; tiny-pattern-ts is an expression and does not
need one
The [TC39 pattern-matching proposal](https://github.com/tc39/proposal-pattern-matching)
is still stage 1, so userland libraries remain the only option today.
## License ## License
MIT © 2026 tmu. See [LICENSE](./LICENSE). MIT © 2026 tmu. See [LICENSE](./LICENSE).
+6 -6
View File
@@ -23,15 +23,15 @@ 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<T, Record<K, V>>` still passes `never` to both the `x` and `undefined` handlers @done ✔ 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<T, Record<K, V>>` still passes `never` to both the `x` and `undefined` handlers @done
Documentation: 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 section order: title, tagline, Synopsis, Description, Requirements, Examples, API, License, Contributing
→ previous Examples order: literal/exhaustive, typeof, structural/discriminated unions, when, any → previous Examples order: literal/exhaustive, typeof, structural/discriminated unions, when, any
☐ Create `examples/` directory with runnable snippets ☐ Create `examples/` directory with runnable snippets
☐ Add comparison section vs. other TS pattern-matching libs in Readme.md ✔ Add comparison section vs. other TS pattern-matching libs in Readme.md @done
☐ Why do we do this? => exhaustiveness encoded type safe ✔ Why do we do this? => exhaustiveness encoded type safe @done
☐ Why this form? little syntax, data last, very small, autocomplete, strict typing in the handler; for more features use ts-pattern ✔ Why this form? little syntax, data last, very small, autocomplete, strict typing in the handler; for more features use ts-pattern @done
☐ Write migration guide for users coming from discriminated unions ✔ Write migration guide for users coming from discriminated unions @done (9/24/2026, 10:21:17 PM)
☐ Create backlog tasks for implementation ✔ 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 ✔ 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: Maintenance:
+2 -1
View File
@@ -45,7 +45,8 @@
"todotasks", "todotasks",
"connor", "connor",
"injective", "injective",
"injectivity" "injectivity",
"userland"
], ],
"ignorePaths": ["dist", "node_modules", "coverage", "*.svg", ".gitignore"] "ignorePaths": ["dist", "node_modules", "coverage", "*.svg", ".gitignore"]
} }
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "tiny-pattern-ts", "name": "tiny-pattern-ts",
"version": "0.8.1", "version": "0.8.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "tiny-pattern-ts", "name": "tiny-pattern-ts",
"version": "0.8.1", "version": "0.8.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"type-fest": "^5.9.0" "type-fest": "^5.9.0"
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "tiny-pattern-ts", "name": "tiny-pattern-ts",
"version": "0.8.1", "version": "0.8.2",
"description": "Pattern matching for TypeScript/ESM environments (F#-style, not regex)", "description": "Exhaustive, type-safe pattern matching for TypeScript",
"keywords": [ "keywords": [
"adt", "adt",
"algebraic-data-types", "algebraic-data-types",