26 Commits
Author SHA1 Message Date
tmu 45df45df4b 🚀 Release 0.8.3
CI / release-gate (push) Successful in 3s
CI / build (push) Successful in 36s
CI / maintain (push) Successful in 16s
CI / publish (push) Failing after 18s
2026-09-28 22:02:16 +00:00
tmu 21b628f91d 🔀 Merge chore/prune-backlog-and-upgrade-deps into main 2026-09-28 21:59:57 +00:00
tmu 3c802ad7df ⬆️ Bump the pi-lsp pin to 0.0.47
The agent-side LSP extension is declared in `.pi/settings.json`, not in
`package.json`, so neither `npm update` nor `maintain:outdated` sees it.
Bump it with `pi install npm:@spences10/pi-lsp@0.0.47 -l`, which
rewrites the pin and refreshes the gitignored `.pi/npm/` install cache,
then reformat the file: pi writes it back in its own two-space style,
which `check:oxfmt` rejects.

0.0.47 bounds the project-binary trust prompt — it follows tool
cancellation and times out after 30 s, returning a tool error instead of
leaving the session stuck in `Working`, and an allow-once decision now
survives an idle language-server restart. The read-only usage and the
`tsc --lsp` wiring are unchanged, so `development/tooling.md` only moves
the recorded version. No changelog note: the pin is agent tooling, not
shipped code, as when it was adopted (863198d).

The running pi session still has 0.0.46 loaded; restart pi to pick up
the new version.
2026-09-28 21:58:11 +00:00
tmu 5025fa3870 🔧 Drop the redundant knip entry pattern
knip derives the public entry from package.json `exports` and resolves
it to src/index.ts itself, so naming the file in `entry` is redundant:
maintain:knip reported it as a configuration hint, and the scan is now
silent. Verified the barrel is still treated as the entry — the scope
is clean with and without dist/, and with src/index.test.ts removed.

Supersedes the workaround recorded in development/tooling.md, which
silenced an unused-file report for the barrel that the current entry
derivation no longer produces. Rewrite the section to state the
behaviour and keep the rejected `paths` mapping.
2026-09-28 21:55:05 +00:00
tmu eb1acbd9d8 ⬆️ Upgrade dependencies
Bump every direct dependency to the latest registry version: cspell
10.3.5, oxfmt 0.71.0, oxlint 1.86.0, oxlint-tsgolint 7.0.2003,
@types/node 26.6.3, the LSP protocol types 3.18.4 and 85 transitive
entries. oxfmt is the only range widened (^0.70.0 -> ^0.71.0), because
0.x minors are breaking under semver and `npm update` stops at the
range; the rest moved within their existing ranges.

`npm run maintain:outdated` is clean and `npm run verify` is green with
no rule or format fallout. `maintain:knip` reports one configuration
hint — the redundant `src/index.ts` entry pattern in knip.json — but
knip did not move, so the hint predates this bump and stays advisory.
2026-09-28 21:47:05 +00:00
tmu df09d3ae61 📝 Prune the completed backlog items
Remove every done/cancelled item. Git history is the archive and each
item's rationale already lives in the README, in development/ or in a
released changelog entry; none was recorded only here, so no item had to
be rescued into development/ before it could go.

- drop the `v1.0:` and `Matcher:` projects too: their last open task is
  done, so those headers would stand empty
- summarise the change under `[Unreleased]` in the changelog
2026-09-27 20:41:59 +00:00
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
tmu ccbd0a297c 🔀 Merge feature/doc-tests into main
CI / release-gate (push) Successful in 3s
CI / build (push) Successful in 29s
CI / publish (push) Skipped
CI / maintain (push) Successful in 15s
2026-09-24 13:51:41 +00:00
tmu 400fe902e0 ♻️ Run doc-test generation as an explicit CI step
Drop the `pretest:ci` lifecycle hook and call `create:doc-tests` from the
`build` job before `test:ci`. The hook hid the step from the job log and
made `test:ci` behave differently under npm than when run directly.
2026-09-24 13:08:45 +00:00
tmu 4dc58395c0 ♻️ Run doc tests outside the coverage gate
An example that exercises a line no hand-written test reaches would let
the `--100` gate pass on documentation alone. Node has no file-level
exclusion, and c8's `--exclude` only filters the report, so run the two
in separate processes: `test:coverage` runs c8 over the tracked tests
only (`git ls-files`, which skips the untracked generated files), and
`test:doc` runs the generated examples without c8. `test:ci` chains
them.
2026-09-24 12:52:42 +00:00
tmu a9b588e1c3 ♻️ Drop assert imports from doc fences
`assert` is the sole injected exception: the generated test always binds
it, so a fence must not import it. Revert the README imports and the
generator's node:assert merging; the fences keep the assigned variables
and `assert.equal` calls, and the generator again rejects a fence that
imports node:assert.
2026-09-24 12:17:24 +00:00
tmu d0dd8641f2 ✅ Assert README example results
Assign every documented matcher result to a variable and assert it with
`assert.equal`, so the compiled README doc-tests verify behavior instead
of merely running the code. The examples import `node:assert` themselves
to stay copy-pasteable; the generator now merges that import into its
prelude assert rather than rejecting it (only `node:test` remains
reserved).
2026-09-24 12:13:36 +00:00
tmu 30d97a9209 ✨ Compile doc code fences into tests
Every `ts`-tagged fence in README.md / CONTRIBUTING.md now becomes an
executed `node:test` case in a gitignored generated file, so a
documented example cannot drift from the API. The generator hoists and
merges the leading imports, rewrites the library specifier to the
`#test-tiny-pattern-ts` alias, and rejects a fence with no describing
paragraph or one that re-imports a prelude module.

CI regenerates via `pretest:ci`; `create:doc-tests` runs the generator,
formats, then typechecks the output against a scoped tsconfig that
relaxes `noUnusedLocals`. c8's default excludes already omit the
generated `*.test.ts`, so `test:ci` is unchanged.
2026-09-24 11:53:29 +00:00
tmu d1963b0329 🔀 Merge feature/api-surface into main
CI / release-gate (push) Successful in 3s
CI / build (push) Successful in 26s
CI / publish (push) Skipped
CI / maintain (push) Successful in 15s
2026-09-23 22:51:21 +00:00
tmu 0bde38d6e4 📝 Summarize the API docs work in the changelog 2026-09-23 22:51:11 +00:00
tmu 38484aa16d 📝 Refresh license year, backlog notes and JSDoc
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.
2026-09-23 22:50:05 +00:00
tmu c878e60ff2 📝 Bind the handler-map function in every example
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.
2026-09-23 22:33:43 +00:00
tmu c8bee95088 📝 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.
2026-09-23 22:25:39 +00:00
tmu 5ddbbdc183 ♻️ 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.
2026-09-23 22:19:02 +00:00
tmu 9c9468fa3c 📝 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.
2026-09-23 22:11:22 +00:00
tmu f485b1bae2 ✨ 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.
2026-09-23 21:39:15 +00:00
26 changed files with 2005 additions and 404 deletions

No files matched your search

+4
View File
@@ -77,6 +77,10 @@ jobs:
- run: npm ci - run: npm ci
- run: npm run build - run: npm run build
- run: npm run check - run: npm run check
# Compile the prose examples into gitignored tests. Kept as an
# explicit step (not a `pretest:ci` hook) so it is visible in the
# job log and `test:ci` stays a plain command.
- run: npm run create:doc-tests
# Fails the build below 100% coverage on `src/` (`c8 --all --100`); # Fails the build below 100% coverage on `src/` (`c8 --all --100`);
# the same run produces the report published below. See # the same run produces the report published below. See
# development/ci.md § Coverage threshold. # development/ci.md § Coverage threshold.
+4
View File
@@ -1,6 +1,10 @@
node_modules node_modules
dist dist
coverage coverage
# Generated doc-tests (scripts/create-doc-tests.ts); the folder is kept via .gitkeep
src/doc-test/__generated__/*.test.ts
!src/doc-test/__generated__/.gitkeep
*.log *.log
*.tsbuildinfo *.tsbuildinfo
*.local *.local
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"packages": ["npm:@spences10/pi-lsp@0.0.46"] "packages": ["npm:@spences10/pi-lsp@0.0.47"]
} }
+2 -1
View File
@@ -7,10 +7,11 @@ 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.
- **Touched a `ts`-tagged fence in `README.md` / `CONTRIBUTING.md`?** Run `npm run create:doc-tests` first: it regenerates the gitignored tests under `src/doc-test/__generated__/`, formats and type-checks them, so `verify` executes the documented example. CI runs it in an explicit step before `test:ci`; the fast local tiers do not. See [development/docs.md](./development/docs.md).
- **On commit:** write a good message (see [CONTRIBUTING.md § Commit messages](./CONTRIBUTING.md#commit-messages)). Lefthook's pre-commit hook already runs the fast, offline, staged-file checks — don't run them by hand. If the hook fails on style, `npm run fix`, restage, recommit. - **On commit:** write a good message (see [CONTRIBUTING.md § Commit messages](./CONTRIBUTING.md#commit-messages)). Lefthook's pre-commit hook already runs the fast, offline, staged-file checks — don't run them by hand. If the hook fails on style, `npm run fix`, restage, recommit.
- **Document decisions where the next maintainer will look:** rationale, rejected alternatives and known issues go in `development/<category>.md` (see [development/README.md](./development/README.md)); the actionable rule stays in [CONTRIBUTING.md](./CONTRIBUTING.md) and links to it. Write each fact once — never copy the rule into `development/` or the reason into `CONTRIBUTING.md` — and change both in the same commit when a rule changes. - **Document decisions where the next maintainer will look:** rationale, rejected alternatives and known issues go in `development/<category>.md` (see [development/README.md](./development/README.md)); the actionable rule stays in [CONTRIBUTING.md](./CONTRIBUTING.md) and links to it. Write each fact once — never copy the rule into `development/` or the reason into `CONTRIBUTING.md` — and change both in the same commit when a rule changes.
- **`npm run maintain` is NOT part of the feature loop.** Its scans are advisory, never a gate; run them only on an explicit maintenance / update-deps branch. - **`npm run maintain` is NOT part of the feature loop.** Its scans are advisory, never a gate; run them only on an explicit maintenance / update-deps branch.
+18 -1
View File
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.8.3] - 2026-09-28
- upgrade dependencies: oxfmt 0.71 (the only range widened), oxlint 1.86,
oxlint-tsgolint 7.0.2003, cspell 10.3.5 and the rest within their existing
ranges; no rule or format fallout
- prune the completed items from the backlog; their rationale already lives in
the README, `development/` and the released changelog entries
## [0.8.2] - 2026-09-25
- write the README's Synopsis and Examples sections
- document the public API in the README
- add TSDoc to the four public matcher factories
- document alternatives
## [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/`,
@@ -101,7 +116,9 @@ 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.3...main
[0.8.3]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.8.2...0.8.3
[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
+21 -5
View File
@@ -22,6 +22,8 @@ the rules so agents and humans don't diverge.
- **Test:** `npm run test`, `npm run test:ci` - **Test:** `npm run test`, `npm run test:ci`
- **Watch:** `npm run watch` - re-runs tests on file save, humans only - **Watch:** `npm run watch` - re-runs tests on file save, humans only
- **Checks:** `npm run check`, `npm run fix` - **Checks:** `npm run check`, `npm run fix`
- **Doc tests:** `npm run create:doc-tests` — compile the `ts`-tagged fences
in the prose docs into executed, gitignored tests
- **Verify:** `npm run verify` — the definition of done - **Verify:** `npm run verify` — the definition of done
- **Maintenance:** `npm run maintain` — advisory only - **Maintenance:** `npm run maintain` — advisory only
- **Individual fixes:** `npm run fix:oxfmt`, `npm run fix:oxlint` - **Individual fixes:** `npm run fix:oxfmt`, `npm run fix:oxlint`
@@ -87,6 +89,17 @@ Per [AGENTS.md § Never do](./AGENTS.md#never-do), reach green honestly — fix
types, never suppress the checks you can't make pass. Full rationale: types, never suppress the checks you can't make pass. Full rationale:
[development/testing.md](./development/testing.md). [development/testing.md](./development/testing.md).
## Documentation examples
Every `ts`-tagged fence in `README.md` / `CONTRIBUTING.md` is compiled into an
executed test, so a documented example cannot drift from the API. Describe each
fence with the paragraph directly above it (that text becomes the test title),
and keep its library import self-contained;
`npm run create:doc-tests` regenerates, formats and type-checks the tests under
`src/doc-test/__generated__/`. CI runs it in an explicit step before `test:ci`,
so run it yourself before `npm run verify` when you touched a fence. Why:
[development/docs.md](./development/docs.md).
## Code style and formatting ## Code style and formatting
`oxfmt` is the formatter and `oxlint` is the linter (with type-aware rules). `oxfmt` is the formatter and `oxlint` is the linter (with type-aware rules).
@@ -115,18 +128,21 @@ intended to run. A `<prefix>:<name>` script is implicitly aggregated by a
`<prefix>` script (if one exists) and run by the corresponding lefthook hook or `<prefix>` script (if one exists) and run by the corresponding lefthook hook or
CI step. Pick the prefix that matches the script's lifecycle: CI step. Pick the prefix that matches the script's lifecycle:
- `create:*` — front doors of the repo's own workflow; these mutate git state - `create:*` — front doors of the repo's own workflow; these produce or mutate
rather than the source. `create:branch` opens a unit of work, `create:finish` workflow artifacts (git state, generated doc-tests) rather than the
hand-written source. `create:branch` opens a unit of work, `create:finish`
closes the branch half, `create:release` closes the release half closes the branch half, `create:release` closes the release half
(maintainer-only). No bare `create` aggregator on purpose. (maintainer-only), and `create:doc-tests` regenerates the compiled prose
examples. No bare `create` aggregator on purpose.
- `check:*` — read-only verification; never modifies files. Aggregated by - `check:*` — read-only verification; never modifies files. Aggregated by
`npm run check`. `npm run check`.
- `fix:*` — mutating counterpart of a `check:*` script. Aggregated by - `fix:*` — mutating counterpart of a `check:*` script. Aggregated by
`npm run fix`; the diff is the review surface. `npm run fix`; the diff is the review surface.
- `test:*` — test scripts. `test` is the canonical entry point (`check:tsc` + - `test:*` — test scripts. `test` is the canonical entry point (`check:tsc` +
unit tests); `test:unit` skips the typecheck for fast local iteration; unit tests); `test:unit` skips the typecheck for fast local iteration;
`test:ci` runs the suite under c8 and fails below 100% coverage on `src/` `test:coverage` runs c8 over the hand-written tests only; `test:doc` runs the
(CI-only; `verify` stays coverage-free). generated doc examples without coverage; `test:ci` chains the two and fails
below 100% coverage on `src/` (CI-only; `verify` stays coverage-free).
- `watch:*` — long-running watchers for the manual inner dev loop. Aggregated by - `watch:*` — long-running watchers for the manual inner dev loop. Aggregated by
`watch`. `watch`.
- `maintain:*` — advisory repo-maintenance scans: read-only, but whole-project - `maintain:*` — advisory repo-maintenance scans: read-only, but whole-project
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2025 tmu Copyright (c) 2026 tmu
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+279 -9
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,9 +59,202 @@ 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
Yet to be implemented The package exports four factories. Two axes pick one:
- **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<T>()` | the value is the union and all handlers return the same type | one common `R` |
| `getPrimitiveUnionMatcherW<T>()` | the value is the union and handlers return different types | union of the handlers |
| `getTaggedUnionMatcher<T>()` | the value is a discriminated object and all handlers return the same type | one common `R` |
| `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
reuse it; the [Examples](#examples) do this, so the builder is allocated once.
### 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 matchAnswer = getPrimitiveUnionMatcher<"yes" | "no">();
const reply = matchAnswer({
yes: () => "agreed",
no: () => "declined",
});
const answer = reply("yes");
assert.equal(answer, "agreed");
```
Add a fallback as the second argument to leave members unhandled; the fallback
receives the remainder:
```ts
import { getPrimitiveUnionMatcher } from "tiny-pattern-ts";
const matchLabel = getPrimitiveUnionMatcher<"yes" | "no" | "maybe">();
const label = matchLabel(
{ yes: () => "agreed", no: () => "declined" },
(other) => `not sure: ${other}`, // other: "maybe"
);
const answer = label("yes");
assert.equal(answer, "agreed");
const fallback = label("maybe");
assert.equal(fallback, "not sure: 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 matchShape = getTaggedUnionMatcher<Shape>()("kind");
const area = matchShape({
circle: (s) => Math.PI * s.radius ** 2,
square: (s) => s.side ** 2,
});
const circleArea = area({ kind: "circle", radius: 2 });
assert.equal(circleArea, Math.PI * 4);
const squareArea = area({ kind: "square", side: 3 });
assert.equal(squareArea, 9);
```
`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
@@ -53,9 +286,46 @@ 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 © 2025 tmu. See [LICENSE](./LICENSE). MIT © 2026 tmu. See [LICENSE](./LICENSE).
## Contributing ## Contributing
-22
View File
@@ -7,30 +7,8 @@ Backlog and tracking for tiny-pattern-ts. Managed in vscode-todotasks format.
Setup: 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:
☐ 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
✔ Test coverage meets threshold @done
✔ Achieve 100% branch coverage on `src/primitive-union.ts` @done
✔ Achieve 100% branch coverage on `src/index.ts` @done
Matcher:
✔ when using a union type as a property, the current behavior of tagged union matcher is @done
to pass never to handler parameters
→ new matcher function needed or can be fixed in tagged union 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
Documentation: Documentation:
☐ Bring README.md back to its previous form — synopsis and examples restored, in the correct place
→ 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 ☐ Create `examples/` directory with runnable snippets
☐ Add comparison section vs. other TS pattern-matching libs in Readme.md
☐ 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
Maintenance: Maintenance:
☐ Serve CI coverage over a tiny self-hosted webserver (replace the zip artifact) @low ☐ Serve CI coverage over a tiny self-hosted webserver (replace the zip artifact) @low
+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"]
} }
+1
View File
@@ -24,6 +24,7 @@ One file per category:
| [workflow.md](./workflow.md) | Branching and merging, script prefixes, feedback tiers, commit messages | | [workflow.md](./workflow.md) | Branching and merging, script prefixes, feedback tiers, commit messages |
| [tooling.md](./tooling.md) | Toolchain choices and configuration, editor setup | | [tooling.md](./tooling.md) | Toolchain choices and configuration, editor setup |
| [testing.md](./testing.md) | Test strategy and type-driven development | | [testing.md](./testing.md) | Test strategy and type-driven development |
| [docs.md](./docs.md) | Validating the Markdown code fences in the prose docs |
| [ci.md](./ci.md) | CI pipeline, runner image, coverage serving | | [ci.md](./ci.md) | CI pipeline, runner image, coverage serving |
| [publishing.md](./publishing.md) | Release and npm publishing | | [publishing.md](./publishing.md) | Release and npm publishing |
+61
View File
@@ -0,0 +1,61 @@
# Docs
Why the prose documentation is maintained the way it is. The actionable rules
are in [CONTRIBUTING.md](../CONTRIBUTING.md); this file records the rationale.
## Validating Markdown code fences
#### Decision (2026-11)
Compile every `ts / `typescript fence in the prose docs into a real
`node:test` case under `src/doc-test/__generated__/`, typechecked by a scoped
`tsc` project and executed by `node --test`.
#### Why
- A documented example is a promise about the API. Left unchecked it drifts the
moment a signature changes, and a reader copies broken code.
- The repo runs TypeScript 7, the native/Go compiler. It exposes **no legacy JS
compiler API** (`ts.createProgram`, `ts.transpileModule`, `ts.createSourceFile`
are all `undefined`; `Object.keys(require("typescript"))` is
`["version", "versionMajorMinor"]`). So `@typescript/vfs`, the type-aware
`eslint-plugin-markdown` and `docs-ts` / `@effect/docgen` cannot run here.
- `node --check` parses as JS and rejects valid TS type annotations, so it is not
a gate. The only faithful validator is the `tsc` **CLI**, which means emitting
real `.ts` files and letting the existing `check:tsc` / `node --test` pipeline
judge them.
#### Rejected
- **A packaged doc-test tool** (see above) — no usable compiler API on TS 7.
- **Embedding a typecheck in the generator** — duplicates the gate, and would
not exercise the repo's own resolution.
- **`node --check`** — wrong language level.
#### Known issue
- Scanned sources are hard-coded to `README.md` and `CONTRIBUTING.md`. A
`docs/` + `examples/` list is the natural extension; `development/` must never
be scanned (its fences are illustrative, not compilable).
- The generator hoists and merges leading imports, rewrites `tiny-pattern-ts` to
the `#test-tiny-pattern-ts` source alias, and rejects an example that imports
`node:assert` / `node:test` (the prelude already binds both). Titles are the
immediately preceding paragraph; a fence with no such paragraph is a fatal
error, which keeps every example described.
- `oxlint src/doc-test` reports "No files found" because the generated
`*.test.ts` are gitignored. That is cosmetic: the files are still typechecked
and run.
- The generated tests are `*.test.ts`, which c8's default excludes already keep
out of the `--100` gate. Do not add an `--exclude` for them: passing any
`--exclude` replaces the defaults, so every hand-written test file and
`__tests__/` helper re-enters coverage and the gate fails.
- Generated examples must not run under `c8`: an example could cover a line no
hand-written test reaches, so the coverage gate would pass on documentation
alone. `test:coverage` therefore runs c8 over the tracked tests only
(`git ls-files 'src/*.test.ts'` — the generated files are untracked), and
`test:doc` runs the examples in a separate process without c8. `test:ci`
chains the two, so correctness and coverage stay independent.
- The CI `build` job runs `npm run create:doc-tests` as an explicit step before
`npm run test:ci`, not a `pretest:ci` lifecycle hook: the hook hides the step
from the job log and makes `test:ci` behave differently under npm than when
run directly.
+33 -2
View File
@@ -5,8 +5,39 @@ 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 nothing else. Every exported
function carries TSDoc; the builder types and the `matcher-shared.ts`
vocabulary stay internal.
#### Why
- 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`.
#### 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 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.
## Matcher shape ## Matcher shape
+13 -12
View File
@@ -196,27 +196,28 @@ joining the oxfmt-superseded rules already off.
are part of the public API. are part of the public API.
- The narrower scope keeps the signal high without config-file boilerplate. - The narrower scope keeps the signal high without config-file boilerplate.
### `knip` lists `src/index.ts` as an entry ### `knip` does not list the library entry
#### Decision (2026-09) #### Decision (2026-09)
`knip.json` declares `"entry": ["src/index.ts", "scripts/*.ts"]`. `knip.json` declares `"entry": ["scripts/*.ts"]`; the library entry
`src/index.ts` is not listed.
#### Why #### Why
- Supplying `entry` **replaces** knip's default entry detection, which otherwise - knip derives the public entry from `package.json` `exports` and resolves it to
derives the public entry from `package.json` `exports`. Adding `scripts/*.ts` `src/index.ts` itself, so naming the file is a redundant pattern and
there therefore dropped the library entry, so knip resolved the package through `maintain:knip` reports it as a configuration hint.
its `dist/index.js` output and reported the unreferenced source entry file - Listing `scripts/*.ts` does not switch that derivation off: the scope stays
`src/index.ts` as an unused file. clean with or without `dist/`, and with the barrel's own test removed.
- Naming the source entry restores the link between the public API and the
source graph without pointing knip at build output.
#### Rejected #### Rejected
- `"src/index.ts"` in `entry`: it silenced an `unused files` report for the
barrel, which the derivation above no longer produces; keeping it only adds a
hint.
- `paths` mapping `dist/index.*` back to `src/index.ts`: more config to model a - `paths` mapping `dist/index.*` back to `src/index.ts`: more config to model a
relation the explicit entry states directly, and it would break whenever the relation knip already resolves.
build layout changes.
### `maintain:outdated` ignores `@types/node` ### `maintain:outdated` ignores `@types/node`
@@ -327,7 +328,7 @@ of a hand-rolled JSON-RPC client.
#### Decision (2026-09) #### Decision (2026-09)
`@spences10/pi-lsp` is pinned to `0.0.46` and used read-only. `@spences10/pi-lsp` is pinned to `0.0.47` and used read-only.
#### Why #### Why
+2 -1
View File
@@ -96,7 +96,8 @@ aggregator.
#### Why #### Why
- Both members create something real: a branch, a release. - Every member creates something real: a branch, a release, the compiled
doc-tests.
- It joined both lists in [CONTRIBUTING.md](../CONTRIBUTING.md) alongside its - It joined both lists in [CONTRIBUTING.md](../CONTRIBUTING.md) alongside its
first members, so it could not go invisible the way the retired `use:` prefix first members, so it could not go invisible the way the retired `use:` prefix
did. did.
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"$schema": "./node_modules/knip/schema.json", "$schema": "./node_modules/knip/schema.json",
"entry": ["src/index.ts", "scripts/*.ts"], "entry": ["scripts/*.ts"],
"ignoreDependencies": ["@runwisp/pubv"] "ignoreDependencies": ["@runwisp/pubv"]
} }
+987 -340
View File
File diff suppressed because it is too large. Load diff
+11 -6
View File
@@ -1,7 +1,7 @@
{ {
"name": "tiny-pattern-ts", "name": "tiny-pattern-ts",
"version": "0.8.1", "version": "0.8.3",
"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",
@@ -28,7 +28,8 @@
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
"imports": { "imports": {
"#test-utils/*": "./src/util/__tests__/*" "#test-utils/*": "./src/util/__tests__/*",
"#test-tiny-pattern-ts": "./src/index.ts"
}, },
"exports": { "exports": {
".": { ".": {
@@ -47,7 +48,8 @@
"check:oxfmt": "oxfmt --check ${LEFTHOOK_FILES:-.}", "check:oxfmt": "oxfmt --check ${LEFTHOOK_FILES:-.}",
"check:oxlint": "oxlint ${LEFTHOOK_FILES:-src scripts}", "check:oxlint": "oxlint ${LEFTHOOK_FILES:-src scripts}",
"check:tsc": "tsc", "check:tsc": "tsc",
"clean": "rm -rf dist coverage", "clean": "rm -rf dist coverage src/doc-test/__generated__/*.test.ts",
"create:doc-tests": "node --strip-types scripts/create-doc-tests.ts && sh -c 'oxfmt src/doc-test/__generated__/*.test.ts' && tsc -p src/doc-test/tsconfig.json",
"fix": "npm run fix:oxlint && npm run fix:oxfmt", "fix": "npm run fix:oxlint && npm run fix:oxfmt",
"fix:oxfmt": "oxfmt ${LEFTHOOK_FILES:-.}", "fix:oxfmt": "oxfmt ${LEFTHOOK_FILES:-.}",
"fix:oxlint": "oxlint --fix src scripts", "fix:oxlint": "oxlint --fix src scripts",
@@ -58,7 +60,9 @@
"maintain:knip": "knip --include dependencies,exports,files", "maintain:knip": "knip --include dependencies,exports,files",
"maintain:outdated": "check-outdated --ignore-pre-releases --ignore-packages @types/node", "maintain:outdated": "check-outdated --ignore-pre-releases --ignore-packages @types/node",
"test": "npm run check:tsc && node --test --strip-types \"src/**/*.test.ts\"", "test": "npm run check:tsc && node --test --strip-types \"src/**/*.test.ts\"",
"test:ci": "c8 --all --include \"src/**/*.ts\" --reporter=text --reporter=lcov --reporter=html --100 node --test --strip-types \"src/**/*.test.ts\"", "test:ci": "npm run test:coverage && npm run test:doc",
"test:coverage": "c8 --all --include \"src/**/*.ts\" --reporter=text --reporter=lcov --reporter=html --100 node --test --strip-types $(git ls-files 'src/*.test.ts')",
"test:doc": "node --test --strip-types \"src/doc-test/__generated__/*.test.ts\"",
"test:unit": "node --test --strip-types \"src/**/*.test.ts\"", "test:unit": "node --test --strip-types \"src/**/*.test.ts\"",
"verify": "npm run check && npm run test:unit", "verify": "npm run check && npm run test:unit",
"watch": "npm run watch:test", "watch": "npm run watch:test",
@@ -83,7 +87,8 @@
"expect-type": "1.4.0", "expect-type": "1.4.0",
"knip": "^6.34.0", "knip": "^6.34.0",
"lefthook": "^2.1.12", "lefthook": "^2.1.12",
"oxfmt": "^0.70.0", "mdast-util-from-markdown": "^2.0.3",
"oxfmt": "^0.71.0",
"oxlint": "^1.83.0", "oxlint": "^1.83.0",
"oxlint-tsgolint": "^7.0.2001", "oxlint-tsgolint": "^7.0.2001",
"publint": "^0.3.24", "publint": "^0.3.24",
+445
View File
@@ -0,0 +1,445 @@
import fs from "node:fs";
import path from "node:path";
import type { Code, Heading, Paragraph, PhrasingContent, Root } from "mdast";
import { fromMarkdown } from "mdast-util-from-markdown";
/**
* Compile the TypeScript examples embedded in the prose docs into runnable
* tests, so a doc fence that drifts from the API fails CI. Every ```ts fence in
* the scanned Markdown becomes a `test(...)` in a generated
* `src/doc-test/__generated__/<Name>.test.ts`; the file is typechecked by
* `tsc` and executed by `node --test` exactly like a hand-written test.
*
* Usage: node --strip-types scripts/create-doc-tests.ts
*
* Why regenerate-then-typecheck instead of a lint plugin: development/docs.md §
* Validating Markdown code fences. The repo is TypeScript 7 (the native
* compiler), which does not expose the legacy JS compiler API, so the only
* faithful check is to emit real `.ts` files and let the existing `check:tsc` /
* `node --test` pipeline judge them.
*/
/** The package's public entry, as spelled inside the examples. */
const LIBRARY_SOURCE = "tiny-pattern-ts";
/** Matches the library specifier (single- or double-quoted) inside an import. */
const LIBRARY_SPECIFIER = new RegExp(
`(?<quote>["'])${LIBRARY_SOURCE}\\k<quote>`,
"g",
);
/**
* A source specifier resolved to `src/index.ts` by `package.json#imports`.
* Examples import `from "tiny-pattern-ts"`, which neither `node` nor `tsc`
* resolves to source before `dist/` exists, so the generator rewrites it.
*/
const PRELUDE_IMPORT_SOURCE = "#test-tiny-pattern-ts";
/** Markdown files to scan, relative to the repo root. */
const SOURCES = ["README.md", "CONTRIBUTING.md"];
/** Directory the generated tests are written to (gitignored contents). */
const OUTPUT_DIR = "src/doc-test/__generated__";
/** Fences whose info-string matches one of these are compiled; others skipped. */
const TYPESCRIPT_LANGS: ReadonlySet<string> = new Set(["ts", "typescript"]);
/**
* Modules the generated file already imports. An example that imports one of
* these would collide with the prelude binding (duplicate `test` / `assert`), so
* it is surfaced as a fatal error and the example is rewritten.
*/
const PRELUDE_MODULES: ReadonlySet<string> = new Set([
"node:assert",
"node:test",
]);
/** One line of the prelude every generated file starts with. */
const PRELUDE_TEST = 'import { test } from "node:test";';
const PRELUDE_ASSERT = 'import { strict as assert } from "node:assert";';
/** Indentation applied to every fence body line inside the `test` callback. */
const INDENT = " ";
const EMPTY = "";
const INITIAL_COUNT = 0;
const WRITE_INCREMENT = 1;
const FIRST_ITEM = 0;
const FAILURE_EXIT_CODE = 1;
/** Strip all leading blank lines so the import scan starts at real content. */
const LEADING_BLANK_LINES = /^(?:[ \t]*\r?\n)+/;
/** Collapse the plain text of a title paragraph to one line. */
const WHITESPACE = /\s+/g;
/** Split an info-string like `ts title="x"` into its bare language. */
const LANG_SEPARATOR = /\s+/;
/** One leading `import` statement, including a multi-line named import. */
const IMPORT_STATEMENT =
/^import\s+(?:(?:type\s+)?[\w$*{}\s,]+?\s+from\s+)?["'][^"'\n]+["']\s*;?[ \t]*(?:\r?\n|$)/;
/** `import { a, b } from "src";`, with an optional `type` keyword. */
const NAMED_IMPORT =
/^import\s+(?<isType>type\s+)?\{(?<specifiers>[^}]*)\}\s+from\s+["'](?<source>[^"']+)["']\s*;?$/;
/** The module in a `… from "src"` statement. */
const FROM_SOURCE = /from\s+["'](?<source>[^"']+)["']/;
/** The module in a side-effect `import "src"` statement. */
const SIDE_EFFECT_SOURCE = /^import\s+["'](?<source>[^"']+)["']/;
type Block = Root["children"][number];
class DocTestError extends Error {
public constructor(message: string) {
super(message);
this.name = "DocTestError";
}
}
/** Merge named specifiers that target the same module into one import. */
interface NamedImportGroup {
readonly source: string;
readonly isType: boolean;
readonly names: string[];
}
/** One compiled fence, ready to be wrapped in a `test(...)`. */
interface DocCase {
readonly title: string;
readonly body: string;
}
/** Accumulator threaded through the Markdown walk. */
interface BuilderState {
readonly named: Map<string, NamedImportGroup>;
readonly passthrough: Set<string>;
readonly cases: DocCase[];
title: string | undefined;
}
/** Read one capture group, tolerating the type-level `groups` optionality. */
const groupOf = (match: RegExpExecArray, name: string): string | undefined => {
const { groups } = match;
return groups === undefined ? undefined : groups[name];
};
/** Recursively concatenate the literal text of a single inline node. */
const inlineText = (node: PhrasingContent): string => {
if ("value" in node) {
return node.value;
}
if ("children" in node) {
return node.children.map(inlineText).join(EMPTY);
}
return EMPTY;
};
/** Render a paragraph/heading node to its plain text for use as a test title. */
const textOf = (node: Paragraph | Heading): string =>
node.children.map(inlineText).join(EMPTY).replace(WHITESPACE, " ").trim();
/**
* Split a fence into its leading `import` statements and the executable body.
* Only a contiguous run of imports at the very top is hoisted; anything after
* the first non-import line stays in the body verbatim.
*/
const splitImports = (code: string): { imports: string[]; body: string } => {
const imports: string[] = [];
let rest = code.replace(LEADING_BLANK_LINES, EMPTY);
let match = IMPORT_STATEMENT.exec(rest);
while (rest.startsWith("import") && match !== null) {
const statement = match[FIRST_ITEM] ?? EMPTY;
imports.push(statement.trim());
rest = rest.slice(statement.length).replace(LEADING_BLANK_LINES, EMPTY);
match = IMPORT_STATEMENT.exec(rest);
}
return { imports, body: rest.trim() };
};
/** The module an import statement points at, or `undefined` if unreadable. */
const importSource = (statement: string): string | undefined => {
const fromMatch = FROM_SOURCE.exec(statement);
if (fromMatch !== null) {
return groupOf(fromMatch, "source");
}
const sideEffectMatch = SIDE_EFFECT_SOURCE.exec(statement);
return sideEffectMatch === null
? undefined
: groupOf(sideEffectMatch, "source");
};
/**
* Reject an example that imports a harness-provided module, then rewrite the
* library specifier to the source alias so the emitted file resolves.
*/
const normalizeImport = (statement: string): string => {
const source = importSource(statement);
if (source !== undefined && PRELUDE_MODULES.has(source)) {
throw new DocTestError(
`example imports "${source}", which the harness injects; remove the line:\n ${statement.trim()}`,
);
}
return statement.replace(
LIBRARY_SPECIFIER,
`$<quote>${PRELUDE_IMPORT_SOURCE}$<quote>`,
);
};
/** Split the `{ a, b }` contents of a named import into trimmed specifiers. */
const specifiersOf = (raw: string): string[] =>
raw
.split(",")
.map((name) => name.trim())
.filter((name) => name.length > INITIAL_COUNT);
/** Add named specifiers to the group for `(isType, source)`, merging. */
const addNamedImport = (
named: Map<string, NamedImportGroup>,
ref: Pick<NamedImportGroup, "source" | "isType">,
names: readonly string[],
): void => {
const key = `${ref.isType ? "type:" : "value:"}${ref.source}`;
const existing = named.get(key);
if (existing === undefined) {
named.set(key, {
source: ref.source,
isType: ref.isType,
names: [...names],
});
return;
}
for (const name of names) {
if (!existing.names.includes(name)) {
existing.names.push(name);
}
}
};
/** Sort each hoisted import into a merged named group or a passthrough set. */
const collectImports = (
statements: readonly string[],
named: Map<string, NamedImportGroup>,
passthrough: Set<string>,
): void => {
for (const raw of statements) {
const statement = normalizeImport(raw);
const match = NAMED_IMPORT.exec(statement);
if (match === null) {
passthrough.add(statement);
} else {
addNamedImport(
named,
{
source: groupOf(match, "source") ?? EMPTY,
isType: groupOf(match, "isType") !== undefined,
},
specifiersOf(groupOf(match, "specifiers") ?? EMPTY),
);
}
}
};
/** Render the hoisted imports: merged named groups first, then the rest. */
const renderImports = (
named: ReadonlyMap<string, NamedImportGroup>,
passthrough: ReadonlySet<string>,
): string[] => {
const lines: string[] = [];
for (const group of named.values()) {
const keyword = group.isType ? "import type" : "import";
lines.push(
`${keyword} { ${group.names.join(", ")} } from "${group.source}";`,
);
}
for (const statement of passthrough) {
lines.push(statement);
}
return lines;
};
/** The bare language of a fence's info-string, e.g. `ts` in `ts title="x"`. */
const typescriptLang = (code: Code): string =>
(code.lang ?? EMPTY).trim().split(LANG_SEPARATOR)[FIRST_ITEM] ?? EMPTY;
/** Set the title a following fence will inherit. */
const handleTitleNode = (
state: BuilderState,
node: Paragraph | Heading,
): void => {
state.title = textOf(node);
};
/** Report and reset a non-TS fence that was skipped. */
const skipFence = (state: BuilderState, name: string, code: Code): void => {
process.stderr.write(
`${name}: skipped non-TypeScript fence (lang="${code.lang ?? EMPTY}")\n`,
);
state.title = undefined;
};
/** Compile a described TS fence into a case, or reject it. */
const compileFence = (
state: BuilderState,
name: string,
code: Code,
): DocCase => {
const { title } = state;
if (title === undefined) {
const lang = typescriptLang(code);
throw new DocTestError(
`${name}: a \`\`\`${lang} fence has no preceding paragraph or ` +
`heading to use as its test title — describe the example.`,
);
}
const { imports, body } = splitImports(code.value);
collectImports(imports, state.named, state.passthrough);
return { title, body };
};
/** Compile one TS fence into a case, or reject/skip it. */
const handleCodeNode = (
state: BuilderState,
name: string,
code: Code,
): void => {
const lang = typescriptLang(code);
if (!TYPESCRIPT_LANGS.has(lang)) {
skipFence(state, name, code);
return;
}
state.cases.push(compileFence(state, name, code));
};
/** Route one Markdown block, preserving the "described fence" invariant. */
const handleNode = (state: BuilderState, name: string, node: Block): void => {
if (node.type === "paragraph" || node.type === "heading") {
handleTitleNode(state, node);
} else if (node.type === "code") {
handleCodeNode(state, name, node);
} else {
// Only a paragraph/heading introduces a fence; any other block breaks
// the "immediately preceded" chain.
state.title = undefined;
}
};
/** Walk one Markdown file and collect its cases and hoisted imports. */
const parseDoc = (
name: string,
markdown: string,
): Omit<BuilderState, "title"> => {
const state: BuilderState = {
named: new Map(),
passthrough: new Set(),
cases: [],
title: undefined,
};
for (const node of fromMarkdown(markdown).children) {
handleNode(state, name, node);
}
return {
named: state.named,
passthrough: state.passthrough,
cases: state.cases,
};
};
/** Indent a fence body one level for the body of the `test` callback. */
const indentBody = (body: string): string =>
body
.split("\n")
.map((line) =>
line.length > INITIAL_COUNT ? `${INDENT}${line}` : line,
)
.join("\n");
/** Wrap one compiled fence in an executed `test(...)`. */
const renderCase = (docCase: DocCase): string => {
const indented = indentBody(docCase.body);
return `test(${JSON.stringify(docCase.title)}, () => {\n${indented}\n});`;
};
/** Build the prelude + hoisted imports that top every generated file. */
const buildHeader = (name: string, imports: readonly string[]): string[] => {
const header = [
"// Generated by scripts/create-doc-tests.ts — do not edit by hand.",
`// Source: ${name}`,
EMPTY,
PRELUDE_TEST,
PRELUDE_ASSERT,
];
if (imports.length > INITIAL_COUNT) {
header.push(EMPTY, ...imports);
}
header.push(EMPTY);
return header;
};
/** Turn one Markdown file into the source of its generated test file. */
const buildTestFile = (name: string, markdown: string): string => {
const parsed = parseDoc(name, markdown);
if (parsed.cases.length === INITIAL_COUNT) {
return EMPTY;
}
const imports = renderImports(parsed.named, parsed.passthrough);
const header = buildHeader(name, imports);
const blocks = parsed.cases.map(renderCase);
return `${header.join("\n")}\n${blocks.join("\n\n")}\n`;
};
/** Map a source Markdown path to its generated test path under OUTPUT_DIR. */
const outputFor = (source: string): string =>
path.join(
OUTPUT_DIR,
`${path.basename(source, path.extname(source))}.test.ts`,
);
/** Write a generated file when there is content, else clear a stale one. */
const writeIfAny = (dest: string, name: string, out: string): boolean => {
if (out === EMPTY) {
process.stderr.write(`${name}: no TypeScript fences\n`);
fs.rmSync(dest, { force: true });
return false;
}
fs.writeFileSync(dest, out);
return true;
};
/** Generate the doc-test for one source; return whether a file was written. */
const generateFor = (root: string, source: string): boolean => {
const abs = path.join(root, source);
if (!fs.existsSync(abs)) {
process.stderr.write(`skipped missing ${source}\n`);
return false;
}
const name = path.basename(source);
const out = buildTestFile(name, fs.readFileSync(abs, "utf8"));
return writeIfAny(path.join(root, outputFor(source)), name, out);
};
const main = (): void => {
const root = process.cwd();
fs.mkdirSync(path.join(root, OUTPUT_DIR), { recursive: true });
let written = INITIAL_COUNT;
for (const source of SOURCES) {
if (generateFor(root, source)) {
written += WRITE_INCREMENT;
}
}
process.stdout.write(
`created doc-tests for ${written} file(s) under ${OUTPUT_DIR}/\n`,
);
};
try {
main();
} catch (error) {
if (error instanceof DocTestError) {
process.stderr.write(`${error.message}\n`);
process.exitCode = FAILURE_EXIT_CODE;
} else {
throw error;
}
}
+12
View File
@@ -0,0 +1,12 @@
# Generated doc-tests
`__generated__/` is the output of [`scripts/create-doc-tests.ts`](../../scripts/create-doc-tests.ts):
each `*.test.ts` is compiled from the ```ts fences in a prose doc and is
**gitignored**, not edited by hand. `npm run create:doc-tests` regenerates them,
formats them with `oxfmt`, then type-checks them with
[`tsconfig.json`](./tsconfig.json) — the scoped config that relaxes
`noUnusedLocals` so example-only locals (and the injected `assert`) compile.
CI regenerates them in an explicit step before `test:ci`; the fast local `test` /
`verify` tiers do not. See [development/docs.md](../../development/docs.md) for why the
generator is shaped this way.
View File
Whitespace-only changes.
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["__generated__/**/*.test.ts"],
"exclude": []
}
+8
View File
@@ -1,3 +1,11 @@
/**
* The public entry point of `tiny-pattern-ts`.
*
* Exports the four matcher factories and nothing else; the builder types they
* return and the rest of `src/` are implementation detail.
*
* @module
*/
export { export {
getPrimitiveUnionMatcher, getPrimitiveUnionMatcher,
getPrimitiveUnionMatcherW, getPrimitiveUnionMatcherW,
+41
View File
@@ -96,9 +96,50 @@ const dispatch =
shape as never, shape as never,
); );
/**
* 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 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 matchAnswer = getPrimitiveUnionMatcher<"yes" | "no">();
* const describe = matchAnswer({
* yes: () => "agreed",
* no: () => "declined",
* });
* describe("yes"); // "agreed"
*/
export const getPrimitiveUnionMatcher = < export const getPrimitiveUnionMatcher = <
T extends Matchable, T extends Matchable,
>(): PrimitiveUnionMatcherStrict<T> => dispatch; >(): PrimitiveUnionMatcherStrict<T> => dispatch;
/**
* Create a matcher for a finite primitive universe whose return type is the
* union of every handler's return type.
*
* 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.
* @example
* const matchReply = getPrimitiveUnionMatcherW<"yes" | "no">();
* const reply = matchReply({
* 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; >(): PrimitiveUnionMatcherWidening<T> => dispatch;
+46
View File
@@ -152,10 +152,56 @@ 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
* 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 matchShape = getTaggedUnionMatcher<Shape>()("kind");
* const area = matchShape({
* 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.
*
* 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.
* @example
* const matchShape = getTaggedUnionMatcherW<Shape>()("kind");
* const describe = matchShape({
* circle: () => "round",
* square: () => 4,
* });
* // describe: (shape: Shape) => string | number
*/
export const getTaggedUnionMatcherW = < export const getTaggedUnionMatcherW = <
T extends object, T extends object,
>(): TaggedUnionMatcherWideningFactory<T> => dispatch; >(): TaggedUnionMatcherWideningFactory<T> => dispatch;
+2 -1
View File
@@ -8,5 +8,6 @@
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"verbatimModuleSyntax": true "verbatimModuleSyntax": true
}, },
"include": ["src", "scripts"] "include": ["src", "scripts"],
"exclude": ["src/doc-test"]
} }