85 Commits
Author SHA1 Message Date
tmu 36e317bf17 🔀 Merge feature/ts-compat-ci into main
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 29s
CI / maintain (push) Failing after 13s
CI / compat (push) Successful in 25s
CI / publish (push) Skipped
2026-09-29 21:24:16 +00:00
tmu 0e56fff59e 📝 Check off the CI TypeScript-floor task
The backlog item asked for a 5.0 baseline; the floor is 5.9 (set by type-fest
and the library's own gate), so the text now says "the TypeScript floor".
2026-09-29 21:24:05 +00:00
tmu 24cc007ab0 🔥 Drop redundant not.toBeAny assertions in the fixture
The exact-equality `toEqualTypeOf` assertions already reject `any`, so the
`.not.toBeAny()` guards were redundant. Refresh the fixture header and
development/ci.md to match.
2026-09-29 21:23:15 +00:00
tmu ebe58a9167 📝 Say compat is CI-only and run by hand locally
The pipeline bullet's "(push to `main` / tag)" read like a local push hook.
State plainly that `compat` has no local tier — it is not in `check`, `verify`,
`test:ci` or any hook — and is run manually with `build && test:compat`.
2026-09-29 21:15:01 +00:00
tmu 00ed6bee1e ✅ Assert handler params in the compat fixture
Give the primitive-union handlers a parameter and assert its member type, and
assert the tagged-union widening handler's parameter too. A matcher without
handler-parameter assertions cannot catch a wrong or `any` handler parameter:
the matcher's own `(shape: T) => R` type is built from `T` and the handler
return, so it stays correct even when `Member<T, K>` inversion is broken.
2026-09-29 21:10:15 +00:00
tmu 75807c4bd8 👷 Type-check the TS floor in CI
Add a `compat` job that type-checks the whole suite and a consumer fixture
against the minimum supported TypeScript (5.9), reusing the `dist/` artifact
`build` produced and gating `publish`. The compiler is resolved by npx, so it
never enters `devDependencies` or the local `check`/`verify` loop.

The fixture imports the package by name, resolving the emitted declarations
through the `exports` map; `expectTypeOf` / `.not.toBeAny()` make it reject an
`any`-typed declaration, which a bare compile would accept.

Correct the README consumer floor from >= 5.0 to >= 5.9 (set by `type-fest`)
and drop the `node10` resolution claim, which the exports-only entry never
satisfied.
2026-09-29 20:51:41 +00:00
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
tmu acf9d06ddd 🚀 Release 0.8.1
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 35s
CI / maintain (push) Successful in 14s
CI / publish (push) Failing after 16s
2026-09-23 21:24:42 +00:00
tmu eba60f6569 🔀 Merge chore/coverage-threshold into main 2026-09-23 21:23:37 +00:00
tmu 52ce655d8e 👷 Gate CI at 100% coverage
test:ci now runs c8 with --all --include "src/**/*.ts" --100, so the
build job fails when any runtime file under src/ is untested. --all is
what makes the gate non-vacuous: without it c8 counts only the files the
suite happened to load, and a new untested module stays invisible.

Add src/index.test.ts to load the public barrel, which was previously
never imported at runtime and so read as 0% under --all. matcher-shared.ts
is types-only (an empty runtime image) and carries a file-level c8 ignore
with the reason.

Why 100% and the rejected alternatives: development/ci.md § Coverage
threshold.
2026-09-23 21:12:24 +00:00
tmu 76327a8c47 🔀 Merge chore/duplicate-tag-union-test into main 2026-09-23 20:55:56 +00:00
tmu d7004c0f71 📝 Clarify the duplicate-tag known issue 2026-09-23 20:52:45 +00:00
tmu 69231eb9ac 📝 Note the duplicate-tag tests in the changelog 2026-09-23 20:30:44 +00:00
tmu 7ff371c569 ✅ Pin the duplicate-tag union collapse 2026-09-23 20:30:35 +00:00
tmu 0339a493a2 📝 Track the TypeScript 5.0 CI baseline 2026-09-23 20:28:53 +00:00
tmu 2c856f28e3 🔀 Merge chore/open-universe-guidance into main 2026-09-23 20:01:52 +00:00
tmu 4260a4732c 📝 Document why open universes are rejected
Add a README Caveats subsection with user-facing guidance for the two
open-universe shapes: parse external input at the boundary down to a
finite union, or use a runtime Map registry for extensible domains.
Point the finite-universe bullet at it, and point the rejected
fix/open-universe-* entry in development/library.md back at the new
guidance, so rule and decision cross-reference without duplicating each
other.
2026-09-23 19:51:19 +00:00
tmu 11d364e187 🚀 Release 0.8.0
CI / release-gate (push) Successful in 3s
CI / build (push) Successful in 34s
CI / maintain (push) Successful in 15s
CI / publish (push) Failing after 19s
2026-09-23 17:42:32 +00:00
tmu 8dc0b95320 🔀 Merge fix/union-valued-discriminant into main 2026-09-23 17:37:37 +00:00
tmu 7b90f898d3 ✅ Cover property-union autocomplete and rejection
Add property-union tests for the completion popup, a broad value in the
union, and value/stringification collisions. Rename the existing
property-union tests to a `property union:` prefix so they can be
selected with `--test-name-pattern`, and extend the autocomplete helper
with a `key` option so a probe can complete on a property other than
`kind`.
2026-09-23 16:32:16 +00:00
tmu 1bdf7f0315 🐛 Reject mixed literal and broad universes
`UnsupportedReason` tested `IsLiteral<PatternKey<T>> extends false`.
`IsLiteral` is `boolean` for a union that mixes a literal with a broad
type (`"a" | \`x-${number}\``), so `boolean extends false` is false and
the mix was accepted. Test `extends true` instead, which rejects it.

Found while adding property-union broad-universe coverage; the gate is
shared with the primitive-union matcher.
2026-09-23 16:32:09 +00:00
tmu 55bf6c24a7 🐛 Narrow union-valued and optional discriminants
The tagged-union matcher selected handler parameters with
`Extract<T, Record<K, V>>`, which only keeps a member whose
discriminant is a singleton literal. A property that is itself a
union (`{ color: "red" | "green" | "blue" }`) or optional
(`{ type?: "x" }`) matched no member, so every handler received
`never`, and the fallback saw the whole shape instead of the
unhandled tags.

Replace the selector with `Narrowed`, which distributes over `T`,
drops a member whose `K` cannot take the tag, and narrows `K`. The
`T[K] extends V` fast path keeps an exact member (a discriminated
union's declared interface) untouched. The fallback reuses
`Narrowed` over `Exclude<Tags, HandledTags>`, so a union-valued
property narrows to the unhandled tags rather than the whole shape.

Matching a defined tag on an optional property makes the key
required (`{ type: "x" }`); the `undefined` tag yields
`{ type?: never }` under `exactOptionalPropertyTypes` (absence) or
`{ type?: undefined }` when the property admits an explicit
`undefined`.
2026-09-23 16:04:00 +00:00
tmu e7b5c0284d 🔀 Merge chore/prune-backlog into main 2026-09-23 15:27:05 +00:00
tmu 013bfa883b 📝 Prune the backlog; document the LSP shutdown fix
Remove every done/cancelled backlog item; the reasons they recorded are
already in development/ (library.md, testing.md, tooling.md, workflow.md)
and git history is the archive.

The one exception was the TS 7 LSP shutdown workaround, which lived only in
a code comment. Record it under development/testing.md § Autocomplete
(Known issue) before dropping the bug item.
2026-09-23 15:12:54 +00:00
tmu d99d84d387 🚀 Release 0.7.1
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 34s
CI / maintain (push) Successful in 15s
CI / publish (push) Failing after 17s
2026-09-23 14:56:59 +00:00
tmu 02f64f804e 🔀 Merge fix/knip-library-entry into main 2026-09-23 14:56:15 +00:00
tmu cbaed8fabc 🐛 Stop knip reporting the source entry
Listing scripts/*.ts in knip's `entry` replaced its default entry
detection, which derives the public entry from package.json `exports`.
Adding the CLI scripts therefore dropped the library entry, so knip
resolved the package through dist/index.js and flagged the unreferenced
src/index.ts as unused. Name the source entry alongside the scripts.

Record the rationale in development/tooling.md.
2026-09-23 14:55:27 +00:00
tmu 410903198e 🔀 Merge chore/upgrade-deps into main 2026-09-23 14:51:49 +00:00
tmu 2e4262383c ⬆️ Upgrade dependencies
Bump every direct dependency to the latest registry version: cspell
10.3.3, knip 6.38.0, oxfmt 0.70.0, oxlint 1.85.0, oxlint-tsgolint
7.0.2002 and type-fest 5.10.0, plus their transitive updates. oxfmt is
the only range widened (^0.68.0 -> ^0.70.0); the rest moved within
their existing semver ranges.

`npm run maintain:outdated` is now clean and `npm run verify` is green
with no rule or format fallout. `maintain:knip` still reports the
pre-existing `src/index.ts` false positive (dist-only `exports`), which
predates this bump.
2026-09-23 14:50:29 +00:00
tmu a8f1a05db2 🚀 Release 0.7.0
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 34s
CI / maintain (push) Failing after 16s
CI / publish (push) Failing after 17s
2026-09-23 14:34:51 +00:00
tmu 675fd0bb54 🔀 Merge feature/finite-universes into main
CI / release-gate (push) Successful in 3s
CI / build (push) Successful in 25s
CI / publish (push) Skipped
CI / maintain (push) Failing after 14s
2026-09-23 13:48:11 +00:00
tmu ce287b384f 📝 Note the gate in changelog and backlog
Close the broad-universe type-hole task and cancel the widened-literal
test task, since arbitrary strings no longer fall through to a fallback.
2026-09-23 13:42:25 +00:00
tmu aaeeef1e11 📝 Document the finite-only universe decision
Record why open universes and value/stringification collisions are
rejected, the `Member<T, K>` inversion, and the findings from the
rejected open-universe attempt so they are not re-run.
2026-09-23 13:42:12 +00:00
tmu 3b5269f9e1 🐛 Gate the universe to finite, literal keys
An open universe (`string`, `number`, a template literal) makes the
handler map index-like, so a partial object satisfied the exhaustive
overload and the runtime `dispatch` throw was reachable. A universe
containing a member and its stringification (`"true" | true`, `1 | "1"`)
collapsed two members onto one key.

- reject broad universes and value/stringification collisions at the
  factory, with a diagnostic naming the reason
- invert `PatternKey` against the universe (`Member<T, K>`) instead of
  `PatternParam<K>`, so a standalone `"true"` is typed `"true"`
- intersect `UniverseGate<T>` into the handler and fallback parameters,
  preserving `R` inference and the popup
2026-09-23 13:41:57 +00:00
tmu 1723419e69 🚀 Release 0.6.0
CI / release-gate (push) Successful in 3s
CI / build (push) Successful in 33s
CI / maintain (push) Failing after 13s
CI / publish (push) Failing after 16s
2026-09-22 11:10:34 +00:00
tmu dcd5f8d1c4 🔀 Merge feature/tagged-union-boolean-nullish-tags into main 2026-09-22 11:09:21 +00:00
tmu 4ebe18adb1 ♻️ Align primitive-union matcher names and drop stale lint disables
Rename `MatcherStrict` / `MatcherWidening` to
`PrimitiveUnionMatcherStrict` / `PrimitiveUnionMatcherWidening`, matching
the exported `getPrimitiveUnionMatcher(W)` names and the
`TaggedUnionMatcher*` pair.

Folds in the working-tree lint cleanups: drop the now-unneeded
`typescript/unified-signatures` disables and turn the tagged-union
factory interfaces into type aliases.
2026-09-22 11:05:16 +00:00
tmu 09e1c12270 ♻️ Return dispatch tacitly from the tagged-union factory 2026-09-22 10:57:03 +00:00
tmu 6228b4922d ♻️ Share the Matchable universe between both matchers 2026-09-22 10:54:27 +00:00
tmu d88a4eeb45 📝 Track optional discriminants under the union-property task 2026-09-22 10:45:54 +00:00
tmu fcec637f01 ✅ Check the runtime member in every boolean/nullish handler 2026-09-22 10:36:03 +00:00
tmu f7b7a06f1a ✅ Assert the fallback receives real boolean/nullish tags 2026-09-22 10:33:26 +00:00
tmu c06538d6c1 📝 Check off the tagged-union non-key tags task 2026-09-22 10:23:06 +00:00
tmu e96ca12149 ✨ Allow boolean and nullish tags in tagged unions
A `boolean` / `null` / `undefined` discriminant cannot key a handler
map, so the tagged-union matcher now routes it through the
`PatternKey` / `PatternParam` projection the primitive-union matcher
already used. Both matchers share the projection from
`matcher-shared.ts`; `MapTaggedUnion`, `Handlers`, `HandledMembers`
and `MustBePartial` key off the projected form and invert it to
recover the real member.

Covers exhaustive and fallback dispatch, the redundant-fallback
guard, and the LSP popup offering the tags by name.
2026-09-22 10:22:59 +00:00
tmu b81a8149d9 🔀 Merge chore/rename-primitive-union into main 2026-09-22 09:58:28 +00:00
tmu a88d49ffe2 📝 Check off the primitive-union rename 2026-09-22 09:47:05 +00:00
tmu 02f736507c ♻️ Rename primitive to primitive-union
`getMatcher` / `getMatcherW` become `getPrimitiveUnionMatcher` /
`getPrimitiveUnionMatcherW`; `src/primitive.ts` and its test move to
`src/primitive-union.*`. The `Union` suffix mirrors `getTaggedUnionMatcher`.
2026-09-22 09:46:59 +00:00
tmu 0727117afb 🔀 Merge chore/lsp-symbol-search-rule into main 2026-09-22 09:43:56 +00:00
tmu b293e611b2 📝 Prefer LSP for symbol lookup in AGENTS 2026-09-22 09:43:08 +00:00
tmu 63b020338e 🔀 Merge chore/backlog-notes into main 2026-09-22 09:15:04 +00:00
tmu 32b9ce0987 📝 Note two matcher gaps in the backlog
Record the non-exhaustive broad-type hole in the primitive matcher and the
never-typed handler parameters the tagged-union matcher produces when a
property is a union, so both can be picked up as their own work units.
2026-09-22 09:14:58 +00:00
tmu 434c3a2222 🔀 Merge feature/tagged-union-matcher into main
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 27s
CI / publish (push) Skipped
CI / maintain (push) Failing after 15s
2026-09-21 23:29:48 +00:00
tmu 6280061737 📝 Note the primitive-union rename in the backlog 2026-09-21 23:24:06 +00:00
tmu 23a2776d55 📝 Note the widened-string-union matcher test 2026-09-21 23:21:19 +00:00
tmu 937d84ea48 ♻️ Share matcher internals 2026-09-21 23:15:09 +00:00
tmu 6f4f509b69 ⚡ Inline the tag read into dispatch 2026-09-21 23:02:56 +00:00
tmu 3f22e5ca01 🔥 Drop unused unified-signature disables 2026-09-21 22:53:42 +00:00
tmu 78e9af600d ✅ Cover the discriminant-key popup 2026-09-21 22:36:30 +00:00
tmu 8ab3c6dbc6 📝 Document the tagged-union matcher 2026-09-21 22:14:22 +00:00
tmu c0fc090ee4 ✨ Add a matcher for tagged unions 2026-09-21 22:14:10 +00:00
34 changed files with 5043 additions and 726 deletions

No files matched your search

+39 -1
View File
@@ -77,6 +77,13 @@ jobs:
- run: npm ci
- run: npm run build
- 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`);
# the same run produces the report published below. See
# development/ci.md § Coverage threshold.
- run: npm run test:ci
# Publish this tag's coverage to the self-hosted pages server,
# served read-only at
@@ -112,6 +119,35 @@ jobs:
name: dist
path: dist/
# Consumer typecheck against the minimum supported TypeScript (README
# § Requirements), run over `dist/`'s emitted declarations and the whole
# suite. Deliberately a separate job, not a step in `build`: the compiler is
# a different major picked by `npx`, and it must never enter
# `devDependencies`, the local `check`/`verify` tiers, or the lockfile. See
# development/ci.md § TypeScript compatibility.
compat:
needs: build
runs-on: ubuntu-latest
# Same baked image as `build` — without it this job re-downloads Node
# per run (see docker/Dockerfile).
container:
image: gitea.e1nsnull.de/tmu/act-ci:26.8.2
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .node-version
cache: "npm"
- run: npm ci
# Reuse the exact `dist/` that `check`, `test:ci` and `publint` were
# run against, so the compat gate judges the shipped artifact and
# pays no rebuild.
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- run: npm run test:compat
# Advisory scans (dead code, dependency freshness). Non-blocking: surfaced in
# the Actions tab for visibility, but must never gate a merge — so
# continue-on-error and intentionally NOT in `publish`'s `needs`.
@@ -135,7 +171,9 @@ jobs:
publish:
if: startsWith(gitea.ref, 'refs/tags/')
needs: build
# `compat` gates the release: an artifact that is not consumable at the
# claimed TypeScript floor must never ship.
needs: [build, compat]
runs-on: ubuntu-latest
# Same baked image as `build` — setup-node still owns the registry-url
# `.npmrc` rewrite here; only the Node download is skipped.
+4
View File
@@ -1,6 +1,10 @@
node_modules
dist
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
*.tsbuildinfo
*.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"]
}
+3 -2
View File
@@ -7,10 +7,11 @@ 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)`. Prefer `lsp_references` over `grep -w` for widely-colliding identifiers (`matches`, `type`, …); use `lsp_hover` to read inferred types on generic-heavy code. 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.
- **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.
- **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.
+62 -1
View File
@@ -7,6 +7,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- add a CI `compat` job that type-checks the suite and a consumer fixture
against the minimum supported TypeScript (5.9), consuming the built `dist/`
and gating `publish`
- correct the documented consumer floor to TypeScript >= 5.9 and drop the
unsupported `node10` resolution claim
## [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
- gate CI at 100% coverage: `test:ci` runs c8 with `--all --100` over `src/`,
and a test loads the `index.ts` barrel so it is measured
- pin the duplicate-tag union collapse (members sharing a tag dispatch through
one handler) with tests
## [0.8.0] - 2026-09-23
- reject a universe that mixes a literal with a broad type (e.g.
`"a" | \`x-${number}\``), which the finite-literal gate let through
- narrow the tagged-union matcher's handler parameters for a union-valued or
optional discriminant, and its fallback to the unhandled tags, instead of
passing `never`
## [0.7.1] - 2026-09-23
- upgrade dependencies
## [0.7.0] - 2026-09-23
- reject broad universes (`string`, `number`, template literals) and
value/stringification collisions (`true | "true"`, `1 | "1"`) at the factory
- type handler parameters by the matched member, so a
standalone `"true"` universe is typed `"true"` rather than `true`
## [0.6.0] - 2026-09-22
- add `getTaggedUnionMatcher` / `getTaggedUnionMatcherW` for discriminated unions
- rename the primitive matcher to primitive-union: `getMatcher` /
`getMatcherW` → `getPrimitiveUnionMatcher` / `getPrimitiveUnionMatcherW`, and
`src/primitive.ts` / `src/primitive.test.ts` → `src/primitive-union.*`
## [0.5.0] - 2026-09-21
- reject a fallback when the handler map already covers the universe
@@ -68,7 +122,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- basic setup
[Unreleased]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.5.0...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.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.0]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.6.0...0.7.0
[0.6.0]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.5.0...0.6.0
[0.5.0]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.4.0...0.5.0
[0.4.0]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.3.0...0.4.0
[0.3.0]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.2.0...0.3.0
+30 -6
View File
@@ -20,8 +20,14 @@ the rules so agents and humans don't diverge.
- **Build:** `npm run build`
- **Test:** `npm run test`, `npm run test:ci`
- **Compat (CI-only, manual locally):** `npm run test:compat` — type-check the
suite + a consumer fixture against the minimum supported TypeScript; needs a
built `dist/` and network access (`npx`). Never part of a hook or
`check`/`verify`
- **Watch:** `npm run watch` - re-runs tests on file save, humans only
- **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
- **Maintenance:** `npm run maintain` — advisory only
- **Individual fixes:** `npm run fix:oxfmt`, `npm run fix:oxlint`
@@ -41,7 +47,8 @@ faster tiers catch less, slower tiers are more thorough":
| `npm run verify` | manual | Definition of done: `npm run check` + unit tests, one shot | ~6s |
| `npm run fix` | manual | Auto-resolve fixable issues (lint, format) | ~3s |
| `npm run maintain` | manual / CI (advisory) | `maintain:knip` + `maintain:outdated` (whole-project + network scans) | ~10s |
| CI build (auto) | on push to `main` / tag | `build` job (build + correctness + packaging) — see [.gitea/workflows/ci.yml](./.gitea/workflows/ci.yml) | ~30s+ |
| CI build (auto) | on push to `main` / tag | `build` job (build + correctness + coverage + packaging) — see [.gitea/workflows/ci.yml](./.gitea/workflows/ci.yml) | ~30s+ |
| CI compat (auto) | in CI only | `compat` job — `test:compat` over the built `dist/`; never a local tier (run by hand) — see [compat/](./compat) | ~15s |
| CI maintain (auto, non-blocking) | on push to `main` | `npm run maintain` — reports, never fails the build | ~10s |
| CI publish (auto) | on tag | packaging checks + `publish:publint` / `publish:attw`, then the Gitea release page and `npm publish` (skipped, and the job failed, without `NPM_TOKEN`) | ~15s |
@@ -71,7 +78,7 @@ Every test pairs an `expectTypeOf(...)` with an `assert.*`; keep them together
assertion on the value dispatch passed, not only the type it inferred (see
[development/testing.md § Handler arguments](./development/testing.md#handler-arguments)).
The autocomplete tests (`src/util/__tests__/lsp-completion.test.ts` for the
helper, `src/primitive.test.ts` for the matcher's popup) are the
helper, `src/primitive-union.test.ts` for the matcher's popup) are the
exception —
the language server, not the type system, is the oracle (see
[development/testing.md § Autocomplete](./development/testing.md#autocomplete)).
@@ -87,6 +94,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:
[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
`oxfmt` is the formatter and `oxlint` is the linter (with type-aware rules).
@@ -115,17 +133,23 @@ 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
CI step. Pick the prefix that matches the script's lifecycle:
- `create:*` — front doors of the repo's own workflow; these mutate git state
rather than the source. `create:branch` opens a unit of work, `create:finish`
- `create:*` — front doors of the repo's own workflow; these produce or mutate
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
(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
`npm run check`.
- `fix:*` — mutating counterpart of a `check:*` script. Aggregated by
`npm run fix`; the diff is the review surface.
- `test:*` — test scripts. `test` is the canonical entry point (`check:tsc` +
unit tests); `test:unit` skips the typecheck for fast local iteration;
`test:ci` adds c8 coverage.
`test:coverage` runs c8 over the hand-written tests only; `test:doc` runs the
generated doc examples without coverage; `test:ci` chains the two and fails
below 100% coverage on `src/`; `test:compat` type-checks the suite + a
consumer fixture against the minimum supported TypeScript via `npx` (both
CI-only; `verify` stays coverage- and network-free).
- `watch:*` — long-running watchers for the manual inner dev loop. Aggregated by
`watch`.
- `maintain:*` — advisory repo-maintenance scans: read-only, but whole-project
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2025 tmu
Copyright (c) 2026 tmu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+309 -16
View File
@@ -1,43 +1,336 @@
# 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
`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. 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
- **Node.js >= 26** (`engines` field; pinned via `.node-version`).
- **TypeScript >= 5.0** to consume the published declarations. The emitted `.d.ts`
use `const` type parameters (TS 5.0) and keep their relative `.ts` specifiers;
both resolve on TS >= 5.0 in `node10` / `node16` / `nodenext` / `bundler`.
- **TypeScript >= 5.9** to consume the published declarations. The floor is set
by the `type-fest` types the declarations use and is checked in CI against a
consumer fixture; see [`compat/`](./compat) and
[development/ci.md § TypeScript compatibility](./development/ci.md#typescript-compatibility).
The emitted `.d.ts` keep their relative `.ts` specifiers, which resolve under
`node16` / `nodenext` / `bundler`. The package exposes only an `exports` map
(no `main` / top-level `types`), so the legacy `node10` resolver does not
apply.
- 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
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
- **A value and its stringification collide.** Object keys stringify, so a
universe that mixes a member with the string it stringifies to — `1 | "1"`,
`true | "true"`, `null | "null"` — collapses to a single handler key and both
members are routed to it. Use one form or the other.
- **Only finite universes are supported.** The factory must be given a finite
union of literals; `string`, `number` and template literals are rejected. This
is what lets the exhaustive overload be proven, so the runtime `dispatch`
throw stays unreachable through the typed API.
- **A value and its stringification must not both be present.** Object keys
stringify, so a universe containing both a member and the string it
stringifies to — `1 | "1"`, `true | "true"`, `null | "null"` — is rejected at
the factory. Either form alone is fine, and one value's string form may
coexist with a _different_ value's bare form (`"true" | false`).
- **`symbol` and `bigint` are not supported.** A `symbol` brand is a
compile-time phantom with nothing to match at runtime, and a `bigint` is not a
valid property key; neither satisfies the matcher's universe constraint.
- **`NaN` and `-0` cannot be matched specifically.** They have no literal type,
so both stay part of `number`.
### Why open universes are rejected
An open universe — one carrying a broad member, as in
`type Units = "s" | "ms" | "min" | (string & {})` — is not a dispatch concern.
If the values arrive from outside the program, parse them at the boundary down
to a finite union and match the narrowed result; the openness never reaches the
matcher. If the domain is genuinely extensible, the right shape is a runtime
`Map` of handlers, where "no handler" is a lookup, not a pattern. Either way an
open matcher would abandon the one guarantee this library exists to give —
provable exhaustiveness — to automate what a `switch` and a default arm already
cover. The type-level cost of supporting open universes is recorded in
[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
MIT © 2025 tmu. See [LICENSE](./LICENSE).
MIT © 2026 tmu. See [LICENSE](./LICENSE).
## Contributing
+1 -60
View File
@@ -6,69 +6,9 @@ Backlog and tracking for tiny-pattern-ts. Managed in vscode-todotasks format.
Setup:
☐ Split off template into separate package => pi --session 01a07dde-7050-7054-bb36-1606d7eb2bc3 @low
✔ straighten oxc rules @done
✔ oxc forbids ternary => remove oxc rule @done
✔ oxc wants comments to start comments with a capital letter => remove oxc rule @done
✔ there already exists a /* */ block comment in primitive.ts, => change to multi-line comment @done
✔ Remove example code files and its documentation and its exports from index.ts @high @done
✔ remove src/match.ts and its documentation @high @done
✔ remove src/pattern.ts and its documentation @high @done
✔ remove src/index.test.ts and its documentation @high @done
✔ exports from `src/index.ts` should only be the public API surface @done
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
☐ Achieve 100% branch coverage on `src/primitive.ts`
☐ Achieve 100% branch coverage on `src/index.ts`
Testing:
✔ Cover autocomplete with real completion test cases in the suite @medium @done
→ `src/util/__tests__/lsp-completion.ts` (`#test-utils/…`) is the helper; the suite asserts its labels (see development/testing.md § Autocomplete)
→ wire it into `node --test` so a test asserts the offered labels
→ note: `Parameters<typeof factory>[0]` resolves only the *last* overload; use `@ts-expect-error` call sites for factory negatives, not `not.toExtend<Parameters<…>>`
✔ Drive the LSP completion helper through the LSP protocol library instead of a hand-rolled JSON-RPC client @medium @done
✔ Add `vscode-languageserver-protocol` and record the tooling decision @done
✔ Rewrite `src/util/__tests__/lsp-completion.ts` onto `createMessageConnection` and typed requests @done
✔ Update `development/testing.md § Autocomplete` for the new client @done
✔ Run `npm run verify` and check the task off @done
Matcher:
✔ Clean up: adopt the 3-overload matcher (`src/prototype-ac2.ts`) and delete the prototypes @high @done
→ `getMatcher` / `getMatcherW`, each with overloads `ExhaustiveLoose` → `Fallback` → `Handlers` (order is load-bearing)
→ fold into `src/primitive.ts` / the public API; drop `src/prototype*.ts`
✔ `_` should receive only the unhandled `T` keys, not all of `T` @medium @done
→ the fallback is now a second argument: `(handlers, (s) => …)`, `s: Exclude<T, keyof handlers>`
✔ A fallback for an already-exhaustive handler map must be a compile error @medium @done
→ rejected by an F-bounded constraint on `Handled` (checked *after* inference); a conditional in the fallback parameter is evaluated too early and breaks contextual typing
Bugs:
✔ TS 7 LSP server logs `context canceled` on stderr at shutdown @done
→ `handleExit` returns `io.EOF`, cancelling the background context while `Session.updateWatches` is still in flight; the bare error is flushed to stderr and the server exits 1
→ close stdin after `shutdown` instead of sending `exit`; the server exits cleanly (code 0, no output), kill kept as a fallback
Enhancements:
✔ Allow boolean literals in primitive union patterns (e.g. `true: () => "yes"`) @medium @done
✔ Are there other primitive types that should be supported in union patterns? (e.g. `bigint`, `symbol`) @medium @done
→ added boolean, null and undefined; rejected `symbol` (compile-time brand, nothing at runtime) and `bigint` (not a property key)
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
☐ 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
✔ Document the matcher design paths in `development/` — union vs overload merge, inferred universe (`NoInfer`), conditional `RequireKeys`, cases-first — and why each was abandoned @done
☐ Validate code fences in Markdown (start with README.md) — compile the TypeScript examples against `src/` so the docs cannot drift from the API
Workflow:
✔ Resolve the finish/push tension: `create:finish` leaves `main` ahead of its upstream while `create:branch` refuses until `main` matches upstream — decide whether `finish` should push or `branch` should compare only `BEHIND` (see development/workflow.md) @done
Maintenance:
☐ Serve CI coverage over a tiny self-hosted webserver (replace the zip artifact) @low
@@ -82,3 +22,4 @@ Maintenance:
☐ Add a minimal dir-listing webserver to the gitea docker setup for serving landing page (reuse existing reverse proxy)
☐ CI writes landing page to a shared volume keyed by project + tag (e.g. `/landing/tiny-pattern-ts/<tag>/`)
☐ Browse to `…/tiny-pattern-ts/index.html` in the browser
✔ Add testing with the TypeScript floor in CI @done
+89
View File
@@ -0,0 +1,89 @@
// Consumer smoke test for the minimum supported TypeScript (see README
// § Requirements). It imports the package by name, so it resolves through the
// `exports` map to the emitted `dist/*.d.ts` — including the relative `.ts`
// specifiers they keep — rather than to the source. Run by `npm run test:compat`
// and the CI `compat` job only; never by `check` / `verify`.
//
// Why the `expectTypeOf` assertions: a compile that merely succeeds is a weak
// oracle. An `any`-typed declaration would compile, but `expect-type`'s exact
// equality rejects `any`, so the assertions prove the emitted types are real.
// Every handler *parameter* is asserted too, not just the matcher: the handler's
// member type comes from the `Member` inversion in the declarations, and a wrong
// or `any` parameter would otherwise slip through, since the matcher's own
// `(shape: T) => R` type is built from `T` and the handler returns. See
// development/ci.md § TypeScript compatibility.
import { expectTypeOf } from "expect-type";
import {
getPrimitiveUnionMatcher,
getPrimitiveUnionMatcherW,
getTaggedUnionMatcher,
getTaggedUnionMatcherW,
} from "tiny-pattern-ts";
type ResultCode = "ok" | "created";
const toStatus = getPrimitiveUnionMatcher<ResultCode>()({
ok: (s) => {
expectTypeOf(s).toEqualTypeOf<"ok">();
return "OK";
},
created: (s) => {
expectTypeOf(s).toEqualTypeOf<"created">();
return "CREATED";
},
});
expectTypeOf(toStatus).toEqualTypeOf<(shape: ResultCode) => string>();
// The widening twin keeps each handler's own return type in the union.
const toStatusW = getPrimitiveUnionMatcherW<ResultCode>()(
{
ok: (s) => {
expectTypeOf(s).toEqualTypeOf<"ok">();
return "OK" as const;
},
},
(rest) => {
expectTypeOf(rest).toEqualTypeOf<"created">();
return "CREATED" as const;
},
);
expectTypeOf(toStatusW).toEqualTypeOf<
(shape: ResultCode) => "OK" | "CREATED"
>();
type Contact =
| { kind: "email"; address: string }
| { kind: "phone"; number: string };
const format = getTaggedUnionMatcher<Contact>()("kind")({
email: (e) => {
expectTypeOf(e).toEqualTypeOf<{ kind: "email"; address: string }>();
return e.address;
},
phone: (p) => {
expectTypeOf(p).toEqualTypeOf<{ kind: "phone"; number: string }>();
return p.number;
},
});
expectTypeOf(format).toEqualTypeOf<(shape: Contact) => string>();
const formatW = getTaggedUnionMatcherW<Contact>()("kind")(
{
email: (e) => {
expectTypeOf(e).toEqualTypeOf<{ kind: "email"; address: string }>();
return e.address;
},
},
(rest) => {
expectTypeOf(rest).toEqualTypeOf<{ kind: "phone"; number: string }>();
return rest.number;
},
);
expectTypeOf(formatW).toEqualTypeOf<(shape: Contact) => string>();
export { format, formatW, toStatus, toStatusW };
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "@tsconfig/strictest/tsconfig.json",
"compilerOptions": {
"lib": ["es2024"],
"module": "nodenext",
"target": "es2024",
"types": ["node"],
"skipLibCheck": false,
"noEmit": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true
},
"include": ["../src", "../scripts", "*.ts"],
"exclude": ["../src/doc-test"]
}
+4 -1
View File
@@ -43,7 +43,10 @@
"silverwind",
"idris",
"todotasks",
"connor"
"connor",
"injective",
"injectivity",
"userland"
],
"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 |
| [tooling.md](./tooling.md) | Toolchain choices and configuration, editor setup |
| [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 |
| [publishing.md](./publishing.md) | Release and npm publishing |
+106
View File
@@ -6,6 +6,12 @@ the job graph; this file records why it is shaped the way it is.
## Pipeline
- **`build`** (push to `main` / tag) — build + correctness + packaging.
- **`compat`** (CI only) — type-check the suite and a consumer fixture against
the minimum supported TypeScript; consumes `build`'s `dist/` and gates
`publish`. It has **no local tier**: it never runs in a hook or in
`check` / `verify` / `test:ci`; run it by hand with
`npm run build && npm run test:compat`. See
[§ TypeScript compatibility](#typescript-compatibility).
- **`maintain`** (push to `main`, non-blocking) — `npm run maintain`; reports,
never fails the build.
- **`publish`** (tag) — packaging checks + `publish:publint` / `publish:attw`,
@@ -97,6 +103,106 @@ Leave `act_runner`'s `force_pull` disabled.
(`docker rmi gitea.e1nsnull.de/tmu/act-ci:<version>`); do not reach for
force-pull.
## Coverage threshold
#### Decision (2026-09)
`npm run test:ci` fails below 100% statements / branches / functions / lines
across `src/**/*.ts` (`c8 --all --include "src/**/*.ts" --100`). The gate rides
the `build` job; `npm run verify` stays coverage-free.
#### Why
- The types are the feature, so an untested branch is a hole in the contract,
not a metric to trade off; 100% is the only threshold that means "no hole".
- `--all` counts a `src/` file no test imports. Without it c8 reports only the
files the suite happened to load, so a new untested module is invisible and
the threshold passes vacuously.
- The gate rides `test:ci`, which `build` already runs — no new job or step.
- `verify` stays fast and local; the slower coverage run is a CI-only tier (see
[workflow.md § Feedback tiers](./workflow.md#feedback-tiers)).
#### Rejected
- Per-file thresholds: a global 100% already forces every counted file to 100%.
- A `check:coverage` script: it would re-run the suite or read c8's temp dir,
and no `check:*` script runs tests.
- `--all` without `--include`: it would also sweep `scripts/`, which is not the
shipped surface.
#### Known issue
- `src/matcher-shared.ts` is types only, so its runtime image is empty; c8 still
lists it under `--all`. It carries a file-level `/* c8 ignore start */` with
the reason. Adding runtime code there means removing that directive.
## TypeScript compatibility
#### Decision (2026-09)
A dedicated `compat` job runs `npm run test:compat` — the `npx`-pinned
TypeScript 5.9 compiler (`typescript@5.9.2`) over `compat/tsconfig.json` —
against the `dist/` artifact `build` produced, and `publish` requires it. The
floor is TypeScript 5.9, pinned in the `test:compat` script itself (the single
source of truth) and documented in [README § Requirements](../README.md#requirements).
#### Why
- The compiler is a **different major** from the repo's TypeScript 7, so it is
resolved by `npx` at run time. It must never appear in `devDependencies`: that
would install it for every local `npm ci` and drift the lockfile, which would
put a second compiler in the local `check` / `verify` loop and every editor.
- **CI-only is the point.** `npx` fetches over the network — like `maintain`'s
scans, a network-bound check is never a local feedback tier (see
[workflow.md § Feedback tiers](./workflow.md#feedback-tiers)). It is not in
`check`, `verify`, `test:ci` or any hook; locally it is run **by hand** with
`npm run build && npm run test:compat`.
- **`compat` consumes `build`'s artifact** rather than rebuilding, so it judges
the exact bytes `check`, `test:ci` and `publint` saw.
- **`compat` gates `publish`** because the types are the feature: an artifact
that is not consumable at the advertised floor must not ship.
- **The fixture is a consumer, not a unit test.** `compat/fixture.ts` imports
the package by name (`tiny-pattern-ts`), so it resolves through the `exports`
map to `dist/index.d.ts` and exercises the emitted declarations' relative
`.ts` specifiers — not the source. `expectTypeOf`'s exact-equality assertions
are load-bearing: a bare compile would also pass if a declaration collapsed to
`any`; they reject that.
#### Rejected
- **A `devDependencies` alias** (`npm:typescript@5.9`): installs the legacy
compiler locally, defeating "CI only".
- **A second lockfile / sub-project** (`compat/` with its own `npm ci`): a
pinned, reproducible matrix, but a whole extra lockfile to maintain for one
compiler. `npx -p` is enough.
- **A `paths` / `moduleSuffixes` redirect** to typecheck the _existing_ suite
against `dist/` without touching it: `paths` cannot remap the relative
`./index.ts` imports the tests use; `moduleSuffixes` only lets _missing_
source resolve to suffixed copies, so it would need generated `.compat.ts`
declarations staged into `src/` (plus excludes). Both spend more than the
fixture buys. See the [handover](../backlog.tasks) discussion.
- **Writing the fixture against source** (relative import): it would prove the
source compiles under 5.9, not that the _published_ declarations do, which is
the promise consumers rely on.
- **Replacing `attw`**: `attw` owns the full resolution matrix
(`node10`/`node16`/`nodenext`/`bundler`); `compat` answers only "does the
documented floor compile the artifact".
#### Known issue
- `@tsconfig/node26` cannot be extended: its `lib: ["es2025", ...]` and
`target: es2025` are rejected by 5.9 (`TS6046`). `compat/tsconfig.json`
extends only `@tsconfig/strictest` and sets `lib` / `target: es2024`, the
ceiling 5.9 accepts.
- `skipLibCheck: false` is deliberate — it is what makes the floor honest
(`type-fest` pins it at 5.9), rather than hiding a broken dependency d.ts
behind `true`.
- The version appears in both the `test:compat` script and the README; a floor
bump is a two-file change. The script is authoritative.
- `src/doc-test` is excluded from `compat/tsconfig.json`. The generated examples
are checked against the source by their own project; `test:compat` covers the
suite plus the fixture.
## Coverage serving
#### Decision (2026-09)
+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.
+208 -13
View File
@@ -3,9 +3,41 @@
The type-level design of the public API and the limitations it carries. The
user-facing reference is [README § API](../README.md#api).
The matcher is implemented in `src/primitive.ts` and re-exported from
`src/index.ts` as `getMatcher` / `getMatcherW`; the rest of the library is
placeholder code.
The matchers are implemented in `src/primitive-union.ts` (`getPrimitiveUnionMatcher` /
`getPrimitiveUnionMatcherW`) and `src/tagged-union.ts` (`getTaggedUnionMatcher` /
`getTaggedUnionMatcherW`), re-exported from `src/index.ts`; the rest of `src/`
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
@@ -15,8 +47,8 @@ A factory takes the universe and returns a builder; the builder takes a handler
map and an optional fallback:
```ts
const matcher = getMatcher<"a" | "b">()({ a: (s) => …, b: (s) => … });
const fallback = getMatcher<"a" | "b" | "c">()({ a: (s) => … }, (s) => …);
const matcher = getPrimitiveUnionMatcher<"a" | "b">()({ a: (s) => …, b: (s) => … });
const fallback = getPrimitiveUnionMatcher<"a" | "b" | "c">()({ a: (s) => … }, (s) => …);
```
Exhaustive or fallback is decided **at the call site**, by whether the second
@@ -24,8 +56,8 @@ argument is present. The fallback's parameter is the remainder
`Exclude<T, keyof Handled>`. Only the return-strictness axis remains, so there
are two factories:
- `getMatcher` — one common `R`; the fallback must fit it;
- `getMatcherW` — the union `PatternReturns<Handled> | R`.
- `getPrimitiveUnionMatcher` — one common `R`; the fallback must fit it;
- `getPrimitiveUnionMatcherW` — the union `PatternReturns<Handled> | R`.
Each factory is two overloads whose order is load-bearing:
@@ -75,8 +107,8 @@ Each factory is two overloads whose order is load-bearing:
- **Variance / `const` type parameters / `NoInfer` / `unique symbol` brands /
defaulted type-param guards.** None change inference or evaluation order;
`in`/`out` on the handler map broke contextual typing outright. `NoInfer`
specifically leaks into the emitted `.d.ts`, raising the consumer floor to
TypeScript 5.4 (README promises `>= 5.0`).
specifically leaks into the emitted `.d.ts`, which would raise the consumer
floor above the documented one (see [README § Requirements](../README.md#requirements)).
- **Union merge**, **overload merge with only the exhaustive arm last**,
**inferred universe**, **conditional `RequireKeys`**, **cases-first curried** —
decided against while the API was single-object; their reasons (reported
@@ -92,6 +124,166 @@ Each factory is two overloads whose order is load-bearing:
not a sound "rejected" oracle for a factory. Factory-negative tests use
`@ts-expect-error` call sites (the test file only — the general ban stands).
## Shared internals
#### Decision (2026-09)
`src/matcher-shared.ts` holds the universe-agnostic pieces both matchers use:
`UnaryFn`, `PatternReturns`, `RedundantFallback`, `HandlerMap`, the shared
`Matchable` universe, the `PatternKey` key projection and its `Member` inverse,
and the `Stringified` / `Collisions` / `UnsupportedReason` / `UnsupportedUniverse`
/ `UniverseGate` universe gate.
#### Why
- `RedundantFallback`'s property name is the diagnostic, so one definition
keeps the two matchers' message from drifting; the other pieces appear
verbatim in both public signatures or are the same projection over each
matcher's universe.
- **`Matchable` is one definition, not two.** The primitive-union matcher's
universe and the tagged-union matcher's allowed `Tag` values are the same set,
so aliasing them keeps the two matchers from drifting apart on what they
accept (`symbol`/`bigint` rejected once).
#### Rejected
- **A generic `Matcher<Universe>` over the interface pair, `Handlers`,
`Fallback` and `MustBePartial`.** Each is built from its own universe
(`Tags`/`MapTaggedUnion` vs the primitive values); abstracting over the
F-bounded `Handled` constraint that makes the remainder work risks the
contextual typing it exists to preserve. `Matchable`, the `PatternKey` /
`Member` projection and the `UniverseGate` are the pieces both universes
genuinely share.
## Supported universes
#### Decision (2026-09)
A universe must be a **finite union of literals** with **no
value/stringification collision**. Broad types (`string`, `number`, a template
literal) and `"true" | true` / `1 | "1"` are rejected; the factory intersects
`UniverseGate<T>` (the `UnsupportedUniverse<Reason>` diagnostic) into the
handler and fallback parameters. `Member<T, K>` replaces the `PatternParam<K>`
inversion: the handler parameter is the member(s) of `T` whose `PatternKey` is
`K`, so a standalone `"true"` is `"true"`, not `true`.
#### Why
- **`PatternKey` is not injective.** `"true"` and `true` (and `1` / `"1"`)
share a runtime key, so `PatternParam<K>` cannot recover the member.
`Member<T, K>` inverts against `T`, which is exact.
- **Broad types cannot be proven exhaustive.** An index-like map lets a partial
object satisfy the exhaustive overload and reaches the `dispatch` throw.
Rejecting at the boundary avoids threading an open/closed branch through
`Handlers`, `Fallback` and `MustBePartial`.
- **The finite-literal predicate is `IsLiteral<PatternKey<T>> extends true`.**
`IsLiteral` is `boolean` for a union that mixes a literal with a broad type
(`"a" | \`x-${number}\``), so `extends false`would treat the mix as
supported;`extends true` is the check that rejects it.
- **Collisions are rejected, not merged.** `Member<T, K>` would be sound (the
handler gets the union), but the API is one handler per member; rejecting
keeps `Member` a singleton and the remainder exact.
- **The collision predicate is type-checkable.** `Collisions<T> =
Extract<T, Stringified<T>>` catches numeric collisions too.
- **The gate is an intersection, not a branch,** so `R` inference and the popup
survive; a conditional parameter type would not.
#### Rejected
- **Open universes with a required fallback** (`fix/open-universe-*`): sound,
but left the collision hole and added an `IsLiteral` /
`OpenUniverseNeedsFallback` branch through every handler type. Findings, kept
so they are not re-run: `{}` satisfies an index signature (and `Exact` misses
it); an index signature dominates contextual typing; `R` infers only from a
non-self-referential parameter type (`{ [K in keyof H]: … R … }` gives
`unknown`); an F-bounded guard referencing `keyof Handled` in `Handled`'s own
constraint sees the constraint, not the map; all handlers share one `R` (only
the fallback widens it); `IsLiteral` is the finite-literal predicate. The
user-facing consequence — open universes are a parsing or registry concern,
not a dispatch one — is guidance in README § Why open universes are rejected.
- **`Member<T, K>` without the gate:** sound, but a colliding handler gets a
union and `1 | "1"` stays one runtime key.
- **A round-trip injectivity gate** (`IsEqual<T, PatternParam<PatternKey<T>>>`):
over-rejects standalone `"true"` / `"false"` / `"null"` / `"undefined"`.
- **A case-list / ts-pattern builder:** removes the collision class but drops
the object map (footprint, popup) and reimplements an existing library.
- **Normalize numeric keys to strings:** makes `PatternKey` injective but
changes "numeric keys stay numbers" and defeats the numeric dispatch fast
path.
#### Known issue
- A multi-collision universe lists every collision in the diagnostic.
- The `dispatch` throw is unreachable through the typed API; the throw tests
widen the factory to `Function` to reach it.
## Tagged-union matcher
#### Decision (2026-09)
`getTaggedUnionMatcher` / `getTaggedUnionMatcherW` mirror the primitive-union pair
with one extra curried step for the discriminant key:
```ts
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number };
const area = getTaggedUnionMatcher<Shape>()("kind")({
circle: (s) => Math.PI * s.radius ** 2,
square: (s) => s.side ** 2,
});
const fallback = getTaggedUnionMatcher<Shape>()("kind")(
{ circle: (s) => … },
(s) => …, // s: { kind: "square"; side: number }
);
```
The key is a separate call because `K` is inferred from its literal argument and
`T` is fixed by the first factory; one call could not infer both.
`Discriminated<T>` restricts the key to properties whose values are tags.
#### Why
- **Same fallback/remainder machinery as the primitive-union matcher.** `HandledTags`
recovers the tag values the map handled (`Member<Tags, keyof Handled>`) and
`Narrowed<T, K, Exclude<Tags, …>>` is the fallback's parameter; the
redundant-fallback guard is the same F-bounded constraint. Only the "universe"
changes — `T`'s members instead of primitive values.
- **`T extends object`, not `Record<PropertyKey, unknown>`.** An `interface` has
no implicit index signature, so the `Record` constraint would reject
interface-based unions. The runtime reads the tag off `object` with one
assertion, the tagged twin of the primitive-union dispatch's `shape as string | number`.
- **`Narrowed` distributes over `T`, narrowing `K` to the tag.** A member whose
`K` cannot take the tag drops out; a duplicated tag yields a union of members
instead of dropping one. `T[K] extends V` returns the exact member (a
discriminated union's declared interface) untouched, so the mapped form only
handles a property that is itself a union.
- **A union-valued or optional discriminant is supported.** A single shape whose
property is a union (`{ color: "red" | "green" | "blue" }`) is narrowed per
handler instead of being passed `never`. Matching a defined tag on an optional
property (`{ type?: "x" }`) proves the key is present, so it becomes required
(`{ type: "x" }`); the `undefined` tag narrows it to `{ type?: never }` under
`exactOptionalPropertyTypes` (absence) or `{ type?: undefined }` when the
property explicitly admits `undefined`.
- **A `boolean` / `null` / `undefined` tag goes through the shared
`PatternKey` / `Member` projection.** `Discriminated` admits those tags
(they are in `Tag`), but they cannot key a mapped type, so the handler map is
keyed by the stringified form (`true` → `"true"`) and `Member` inverts it
against the tag set to recover the member. This is the same projection the
primitive-union matcher uses over its universe, which is why it lives in
`matcher-shared.ts`.
#### Known issue
- A tag need not be unique across the union. Two members sharing one is not a
soundness hole: they select a single runtime key, so one handler receiving
their union is the only correct behavior — the key is simply not a
discriminant. The gate rejects only the distinct-value collision
(`true | "true"`), where two values share a key and `Member` can no longer
invert it; see § Supported universes. Pinned by the duplicate-tag tests in
`src/tagged-union.test.ts`.
## Primitive universe
#### Decision (2026-09)
@@ -100,9 +292,12 @@ The universe (`Matchable`) is `string | number | boolean | null | undefined`,
with `boolean` admitted as `true | false`.
`boolean`/`null`/`undefined` are not property keys, so handler-map keys are a
projection (`PatternKey`: each member stringified) and `PatternParam` inverts
it, so callbacks receive the real member (`true`, not `"true"`). The popup
offers `true`, `false`, `null`, `undefined` by name (verified over LSP).
projection (`PatternKey`: each member stringified) and `Member` inverts it
against the universe, so callbacks receive the real member (`true`, not
`"true"`; the standalone string `"true"` stays `"true"`). The popup offers
`true`, `false`, `null`, `undefined` by name (verified over LSP). The same
projection is shared with the tagged-union matcher; see § Tagged-union matcher.
The supported universes are constrained as described in § Supported universes.
#### Why
@@ -112,5 +307,5 @@ offers `true`, `false`, `null`, `undefined` by name (verified over LSP).
`String()` defeats V8's numeric-key path: measured ~2× on number-keyed
dispatch).
- `symbol`/`bigint`/`NaN`/`-0` are rejected, and a member colliding with its
stringification is unguarded: user-facing, stated once in
stringification is rejected by the gate: user-facing, stated once in
[README § Caveats](../README.md#caveats).
+9 -3
View File
@@ -28,14 +28,15 @@ before the implementation.
- Runtime-first (classic red/green): it verifies the value, not the contract,
and the contract is the product.
- Testing the type only: it would not catch handler dispatch or the `_`
fallback (see `src/primitive.test.ts`).
fallback (see `src/primitive-union.test.ts`).
The runner is `node --test --strip-types "src/**/*.test.ts"` and the tiers are
in
[CONTRIBUTING.md § Development commands](../CONTRIBUTING.md#development-commands).
`c8` uses V8 coverage, so the `--strip-types` source is instrumented without a
build step, and the runner relies on the `.ts` import-extension convention (see
[tooling.md](./tooling.md#source-imports-use-ts-extensions)).
[tooling.md](./tooling.md#source-imports-use-ts-extensions)). CI gates that
coverage at 100% (see [ci.md § Coverage threshold](./ci.md#coverage-threshold)).
## Handler arguments
@@ -167,7 +168,7 @@ The script prints the labels the server offers at a `/*COMPLETE*/` marker inside
`<file>` (the marker is stripped before the document is sent). Its `LspSession`
is imported by `src/util/__tests__/lsp-completion.test.ts` — which tests the
helper itself against inline documents, never the library's code — and by
`src/primitive.test.ts`, where the same probe asserts the matcher's popup;
`src/primitive-union.test.ts`, where the same probe asserts the matcher's popup;
the CLI is for manual inspection.
#### Why
@@ -198,6 +199,11 @@ the CLI is for manual inspection.
delay is needed.
- The server answers some requests with a string id (`client/registerCapability`);
the client must tolerate `string | number` ids or the server stalls.
- `LspSession.close()` sends `shutdown` and then closes stdin instead of
sending `exit`. The TS 7 Go server's `handleExit` returns `io.EOF`, cancelling
the background context while a watch update is still in flight, and logs a
bare `context canceled` before exiting 1; EOF on stdin exits 0 with no output.
The kill stays as a fallback for a server that does not exit.
## Known issues
+25 -2
View File
@@ -107,7 +107,7 @@ Type-aware oxlint is enabled via `options.typeAware: true` in `.oxlintrc.json`
#### Decision (2026-09)
A type-aware rule that false-positives **at one site** is silenced with a
source-level `oxlint-disable` directive (see `src/primitive.ts`). A rule that is
source-level `oxlint-disable` directive (see `src/primitive-union.ts`). A rule that is
wrong for a whole **file class** is turned off in a `.oxlintrc.json` `overrides`
entry instead — e.g. `typescript/no-floating-promises` (synchronous
`expectTypeOf` reads as an unhandled promise) and `unicorn/no-null` (intentional
@@ -196,6 +196,29 @@ joining the oxfmt-superseded rules already off.
are part of the public API.
- The narrower scope keeps the signal high without config-file boilerplate.
### `knip` does not list the library entry
#### Decision (2026-09)
`knip.json` declares `"entry": ["scripts/*.ts"]`; the library entry
`src/index.ts` is not listed.
#### Why
- knip derives the public entry from `package.json` `exports` and resolves it to
`src/index.ts` itself, so naming the file is a redundant pattern and
`maintain:knip` reports it as a configuration hint.
- Listing `scripts/*.ts` does not switch that derivation off: the scope stays
clean with or without `dist/`, and with the barrel's own test removed.
#### 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
relation knip already resolves.
### `maintain:outdated` ignores `@types/node`
#### Decision (2026-09)
@@ -305,7 +328,7 @@ of a hand-rolled JSON-RPC client.
#### 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
+2 -1
View File
@@ -96,7 +96,8 @@ aggregator.
#### 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
first members, so it could not go invisible the way the retired `use:` prefix
did.
+1 -1
View File
@@ -1,5 +1,5 @@
{
"$schema": "./node_modules/knip/schema.json",
"entry": ["scripts/*.ts"],
"entry": ["scripts/*.ts", "compat/*.ts"],
"ignoreDependencies": ["@runwisp/pubv"]
}
+1108 -461
View File
File diff suppressed because it is too large. Load diff
+12 -6
View File
@@ -1,7 +1,7 @@
{
"name": "tiny-pattern-ts",
"version": "0.5.0",
"description": "Pattern matching for TypeScript/ESM environments (F#-style, not regex)",
"version": "0.8.3",
"description": "Exhaustive, type-safe pattern matching for TypeScript",
"keywords": [
"adt",
"algebraic-data-types",
@@ -28,7 +28,8 @@
"type": "module",
"sideEffects": false,
"imports": {
"#test-utils/*": "./src/util/__tests__/*"
"#test-utils/*": "./src/util/__tests__/*",
"#test-tiny-pattern-ts": "./src/index.ts"
},
"exports": {
".": {
@@ -47,7 +48,8 @@
"check:oxfmt": "oxfmt --check ${LEFTHOOK_FILES:-.}",
"check:oxlint": "oxlint ${LEFTHOOK_FILES:-src scripts}",
"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:oxfmt": "oxfmt ${LEFTHOOK_FILES:-.}",
"fix:oxlint": "oxlint --fix src scripts",
@@ -58,7 +60,10 @@
"maintain:knip": "knip --include dependencies,exports,files",
"maintain:outdated": "check-outdated --ignore-pre-releases --ignore-packages @types/node",
"test": "npm run check:tsc && node --test --strip-types \"src/**/*.test.ts\"",
"test:ci": "c8 --reporter=text --reporter=lcov --reporter=html node --test --strip-types \"src/**/*.test.ts\"",
"test:ci": "npm run test:coverage && npm run test:doc",
"test:compat": "npx --yes --package typescript@5.9.2 tsc --project compat/tsconfig.json",
"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\"",
"verify": "npm run check && npm run test:unit",
"watch": "npm run watch:test",
@@ -83,7 +88,8 @@
"expect-type": "1.4.0",
"knip": "^6.34.0",
"lefthook": "^2.1.12",
"oxfmt": "^0.68.0",
"mdast-util-from-markdown": "^2.0.3",
"oxfmt": "^0.71.0",
"oxlint": "^1.83.0",
"oxlint-tsgolint": "^7.0.2001",
"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": []
}
+27
View File
@@ -0,0 +1,27 @@
import { strict as assert } from "node:assert";
import { test } from "node:test";
import { expectTypeOf } from "expect-type";
import {
getPrimitiveUnionMatcher,
getPrimitiveUnionMatcherW,
getTaggedUnionMatcher,
getTaggedUnionMatcherW,
} from "./index.ts";
// The published entry point is the barrel (`package.json` exports
// `./dist/index.js`), so every factory must be reachable from here. Importing it
// also loads the module, which is what lets c8's `--all` measure it — see
// development/ci.md § Coverage threshold.
test("index: the public entry point re-exports every matcher factory", () => {
// Assert
expectTypeOf(getPrimitiveUnionMatcher).toBeFunction();
assert.equal(typeof getPrimitiveUnionMatcher, "function");
expectTypeOf(getPrimitiveUnionMatcherW).toBeFunction();
assert.equal(typeof getPrimitiveUnionMatcherW, "function");
expectTypeOf(getTaggedUnionMatcher).toBeFunction();
assert.equal(typeof getTaggedUnionMatcher, "function");
expectTypeOf(getTaggedUnionMatcherW).toBeFunction();
assert.equal(typeof getTaggedUnionMatcherW, "function");
});
+16 -1
View File
@@ -1 +1,16 @@
export { getMatcher, getMatcherW } from "./primitive.ts";
/**
* 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 {
getPrimitiveUnionMatcher,
getPrimitiveUnionMatcherW,
} from "./primitive-union.ts";
export {
getTaggedUnionMatcher,
getTaggedUnionMatcherW,
} from "./tagged-union.ts";
+102
View File
@@ -0,0 +1,102 @@
/* c8 ignore start -- types only: the module has no runtime image to cover */
import type { IsLiteral, IsNever, ValueOf } from "type-fest";
// The primitive-union and tagged-union matchers differ in their universe, but the
// handler/fallback plumbing is identical; these are the shared pieces. The
// boundary is deliberate: `Handlers`, `Fallback` and `MustBePartial` stay with
// each matcher because they are built from its universe. See development/library.md.
// A handler: one universe member in, one return value out.
export type UnaryFn<T, R> = (shape: T) => R;
// The primitive universe a matcher can discriminate. The primitive-union matcher
// uses it directly; the tagged-union matcher uses it as the set of allowed
// discriminant (`Tag`) values. `boolean` is admitted as the pair `true | false`;
// see README § Caveats for the unsupported members.
export type Matchable = string | number | boolean | null | undefined;
// `boolean`, `null` and `undefined` cannot be property keys, so a mapped type
// over a universe that includes one keys each such member by its
// stringification. `Member` inverts that projection against the universe, so a
// handler callback still receives the *real* member (`true`, not `"true"`).
// Both matchers use the projection: the primitive-union matcher over its
// universe, the tagged-union matcher over a discriminant property's values. See
// README § Caveats for the limits.
export type PatternKey<T> = T extends boolean
? T extends true
? "true"
: "false"
: T extends null
? "null"
: T extends undefined
? "undefined"
: T;
// The member(s) of `T` whose `PatternKey` is `K`: the universe-keyed inverse of
// `PatternKey`. The key alone cannot recover the member (`"true"` and `true`
// share it), so the handler parameter is derived from `T` instead. For a
// supported (injective) universe the result is a single member.
export type Member<
T extends Matchable,
K extends PropertyKey,
> = T extends Matchable ? (PatternKey<T> extends K ? T : never) : never;
// The property key a `Matchable` member takes at runtime: booleans, `null` and
// `undefined` stringify, and a numeric literal becomes its decimal string.
export type Stringified<T> = T extends boolean
? T extends true
? "true"
: "false"
: T extends null
? "null"
: T extends undefined
? "undefined"
: T extends number
? `${T}`
: never;
// The members of `T` that are also the stringification of another member, so
// `PatternKey` cannot invert them. `never` means the universe is injective.
export type Collisions<T> = Extract<T, Stringified<T>>;
// Why `T` is not a supported universe, or `never` when it is. A supported
// universe is a finite union of literals with no value/stringification
// collision: only then can `PatternKey` be inverted unambiguously.
export type UnsupportedReason<T extends Matchable> =
IsLiteral<PatternKey<T>> extends true
? [Collisions<T>] extends [never]
? never
: `a value and its stringification collide. Value is "${Collisions<T> & string}"`
: "broad types like string, number and template literals are not supported";
// The diagnostic for an unsupported universe. Extends `HandlerMap` so the
// implementation's `handlers: HandlerMap` stays assignable when the gate is
// intersected into a parameter; the property name is the message.
export type UnsupportedUniverse<Reason extends string> = HandlerMap &
Readonly<Record<`unsupported universe: ${Reason}`, never>>;
// `unknown` for a supported universe (an intersection no-op), the diagnostic
// otherwise. Intersecting rather than branching keeps `R` inference intact.
export type UniverseGate<T extends Matchable> =
IsNever<UnsupportedReason<T>> extends true
? unknown
: UnsupportedUniverse<UnsupportedReason<T>>;
// `Extract` drops optional handlers (`undefined`) so `PatternReturns` also works
// when `P`'s constraint has optional keys.
export type PatternReturns<P> = ReturnType<
Extract<ValueOf<P>, (...args: never[]) => unknown>
>;
// The diagnostic raised when a fallback is supplied for an already-exhaustive
// handler map. Each matcher's `MustBePartial` folds it into `Handled`'s
// constraint so the guard is checked after inference.
export interface RedundantFallback {
readonly "every case is already handled, so the fallback is redundant": never;
}
// The runtime dispatch map the handler maps and fallback erase to.
export type HandlerMap = Record<
string | number,
UnaryFn<never, unknown> | undefined
>;
@@ -9,15 +9,18 @@ import {
LspSession,
} from "#test-utils/lsp-completion.ts";
import { getMatcher, getMatcherW } from "./primitive.ts";
import {
getPrimitiveUnionMatcher,
getPrimitiveUnionMatcherW,
} from "./primitive-union.ts";
// ============================================================================
// API: getMatcher — ✔️ Exhaustive / ✔️ ReturnsStrict
// API: getPrimitiveUnionMatcher — ✔️ Exhaustive / ✔️ ReturnsStrict
// ============================================================================
test("getMatcher: exhaustive pattern infers one common return type", () => {
test("getPrimitiveUnionMatcher: exhaustive pattern infers one common return type", () => {
// Arrange
const factory = getMatcher<"a" | "b">();
const factory = getPrimitiveUnionMatcher<"a" | "b">();
// Act
const matcher = factory({
@@ -42,9 +45,9 @@ test("getMatcher: exhaustive pattern infers one common return type", () => {
assert.equal(matcher("b"), 2);
});
test("getMatcher: dispatches on numeric literal keys", () => {
test("getPrimitiveUnionMatcher: dispatches on numeric literal keys", () => {
// Arrange
const factory = getMatcher<1 | 2>();
const factory = getPrimitiveUnionMatcher<1 | 2>();
// Act
const matcher = factory({
@@ -67,9 +70,9 @@ test("getMatcher: dispatches on numeric literal keys", () => {
assert.equal(matcher(2), 20);
});
test("getMatcher: dispatches on mixed string and numeric keys", () => {
test("getPrimitiveUnionMatcher: dispatches on mixed string and numeric keys", () => {
// Arrange
const factory = getMatcher<"a" | "b" | 1 | 2>();
const factory = getPrimitiveUnionMatcher<"a" | "b" | 1 | 2>();
// Act
const matcher = factory({
@@ -103,9 +106,9 @@ test("getMatcher: dispatches on mixed string and numeric keys", () => {
assert.equal(matcher(2), 20);
});
test("getMatcher: dispatches on boolean literals", () => {
test("getPrimitiveUnionMatcher: dispatches on boolean literals", () => {
// Arrange
const factory = getMatcher<boolean>();
const factory = getPrimitiveUnionMatcher<boolean>();
// Act
const matcher = factory({
@@ -129,9 +132,9 @@ test("getMatcher: dispatches on boolean literals", () => {
assert.equal(matcher(false), 2);
});
test("getMatcher: dispatches on null and undefined", () => {
test("getPrimitiveUnionMatcher: dispatches on null and undefined", () => {
// Arrange
const factory = getMatcher<null | undefined>();
const factory = getPrimitiveUnionMatcher<null | undefined>();
// Act
const matcher = factory({
@@ -154,12 +157,12 @@ test("getMatcher: dispatches on null and undefined", () => {
});
// ============================================================================
// API: getMatcher — ❌ Exhaustive (fallback) / ✔️ ReturnsStrict
// API: getPrimitiveUnionMatcher — ❌ Exhaustive (fallback) / ✔️ ReturnsStrict
// ============================================================================
test("getMatcher: a `_` fallback receives the unhandled keys", () => {
test("getPrimitiveUnionMatcher: a `_` fallback receives the unhandled keys", () => {
// Arrange
const factory = getMatcher<"a" | "b" | "c">();
const factory = getPrimitiveUnionMatcher<"a" | "b" | "c">();
// Act
const matcher = factory(
@@ -187,9 +190,9 @@ test("getMatcher: a `_` fallback receives the unhandled keys", () => {
assert.equal(matcher("c"), 2);
});
test("getMatcher: a `_` fallback routes mixed string and numeric gaps", () => {
test("getPrimitiveUnionMatcher: a `_` fallback routes mixed string and numeric gaps", () => {
// Arrange
const factory = getMatcher<"a" | "b" | 1 | 2>();
const factory = getPrimitiveUnionMatcher<"a" | "b" | 1 | 2>();
// Act
const matcher = factory(
@@ -222,9 +225,11 @@ test("getMatcher: a `_` fallback routes mixed string and numeric gaps", () => {
assert.equal(matcher(2), "fallback");
});
test("getMatcher: a `_` fallback receives unhandled boolean and nullish keys", () => {
test("getPrimitiveUnionMatcher: a `_` fallback receives unhandled boolean and nullish keys", () => {
// Arrange
const factory = getMatcher<"a" | true | false | null | undefined>();
const factory = getPrimitiveUnionMatcher<
"a" | true | false | null | undefined
>();
// Act
const matcher = factory(
@@ -261,12 +266,12 @@ test("getMatcher: a `_` fallback receives unhandled boolean and nullish keys", (
});
// ============================================================================
// API: getMatcherW — ✔️ Exhaustive / ❌ ReturnsStrict
// API: getPrimitiveUnionMatcherW — ✔️ Exhaustive / ❌ ReturnsStrict
// ============================================================================
test("getMatcherW: exhaustive pattern widens to the union of handler returns", () => {
test("getPrimitiveUnionMatcherW: exhaustive pattern widens to the union of handler returns", () => {
// Arrange
const factory = getMatcherW<"x" | "y">();
const factory = getPrimitiveUnionMatcherW<"x" | "y">();
// Act
const matcher = factory({
@@ -291,9 +296,9 @@ test("getMatcherW: exhaustive pattern widens to the union of handler returns", (
assert.equal(matcher("y"), "two");
});
test("getMatcherW: dispatches on mixed string and numeric keys", () => {
test("getPrimitiveUnionMatcherW: dispatches on mixed string and numeric keys", () => {
// Arrange
const factory = getMatcherW<"a" | "b" | 1 | 2>();
const factory = getPrimitiveUnionMatcherW<"a" | "b" | 1 | 2>();
// Act
const matcher = factory({
@@ -329,9 +334,9 @@ test("getMatcherW: dispatches on mixed string and numeric keys", () => {
assert.equal(matcher(2), 20);
});
test("getMatcherW: exhaustive boolean and nullish widen to the union", () => {
test("getPrimitiveUnionMatcherW: exhaustive boolean and nullish widen to the union", () => {
// Arrange
const factory = getMatcherW<boolean | null | undefined>();
const factory = getPrimitiveUnionMatcherW<boolean | null | undefined>();
// Act
const matcher = factory({
@@ -368,12 +373,12 @@ test("getMatcherW: exhaustive boolean and nullish widen to the union", () => {
});
// ============================================================================
// API: getMatcherW — ❌ Exhaustive (fallback) / ❌ ReturnsStrict
// API: getPrimitiveUnionMatcherW — ❌ Exhaustive (fallback) / ❌ ReturnsStrict
// ============================================================================
test("getMatcherW: a `_` fallback widens gaps into the union", () => {
test("getPrimitiveUnionMatcherW: a `_` fallback widens gaps into the union", () => {
// Arrange
const factory = getMatcherW<"x" | "y" | "z">();
const factory = getPrimitiveUnionMatcherW<"x" | "y" | "z">();
// Act
const matcher = factory(
@@ -404,9 +409,9 @@ test("getMatcherW: a `_` fallback widens gaps into the union", () => {
assert.equal(matcher("z"), "z");
});
test("getMatcherW: a `_` fallback widens mixed string and numeric returns", () => {
test("getPrimitiveUnionMatcherW: a `_` fallback widens mixed string and numeric returns", () => {
// Arrange
const factory = getMatcherW<"a" | "b" | 1 | 2>();
const factory = getPrimitiveUnionMatcherW<"a" | "b" | 1 | 2>();
// Act
const matcher = factory(
@@ -447,9 +452,9 @@ test("getMatcherW: a `_` fallback widens mixed string and numeric returns", () =
// Factory contracts — calls that must not compile
// ============================================================================
test("getMatcher factory rejects patterns outside its contract", () => {
test("getPrimitiveUnionMatcher factory rejects patterns outside its contract", () => {
// Arrange
const factory = getMatcher<"a" | "b">();
const factory = getPrimitiveUnionMatcher<"a" | "b">();
// Act / Assert — the calls below must not compile.
// `Parameters<typeof factory>[0]` resolves only the *last* overload, so it
@@ -469,9 +474,9 @@ test("getMatcher factory rejects patterns outside its contract", () => {
factory({ a: () => 1, b: () => 2 }, () => 0);
});
test("getMatcher factory rejects non-exhaustive boolean and nullish maps", () => {
test("getPrimitiveUnionMatcher factory rejects non-exhaustive boolean and nullish maps", () => {
// Arrange
const factory = getMatcher<boolean | null | undefined>();
const factory = getPrimitiveUnionMatcher<boolean | null | undefined>();
// Act / Assert — the calls below must not compile
// @ts-expect-error only `true` is handled; `false`, `null`, `undefined` are not
@@ -490,31 +495,399 @@ test("getMatcher factory rejects non-exhaustive boolean and nullish maps", () =>
);
});
test("getMatcher: an open universe keeps the fallback's remainder open", () => {
test("getPrimitiveUnionMatcher: a standalone stringified literal is typed by the member, not its key", () => {
// `"true"` is a string member whose `PatternKey` is `"true"`; inverting the
// key must recover the string, not the boolean that also stringifies to it.
// Arrange
const factory = getMatcher<string>();
const factory = getPrimitiveUnionMatcher<"true" | "1">();
// Act
const matcher = factory({ a: () => 1 }, (s) => {
// The map's literal keys do not close an open universe, so the
// remainder stays `string` and the fallback is not redundant.
expectTypeOf(s).toEqualTypeOf<string>();
// A strict pattern fixes one `R` for every handler, so this fallback
// cannot return its shape; `typeof` is the strongest claim the value
// makes on its own — any string passes, including `""`.
assert.equal(typeof s, "string");
return 2 as const;
const matcher = factory({
true: (s) => {
expectTypeOf(s).toEqualTypeOf<"true">();
assert.equal(s, "true");
return 1;
},
"1": (s) => {
expectTypeOf(s).toEqualTypeOf<"1">();
assert.equal(s, "1");
return 2;
},
});
// Assert
expectTypeOf(matcher).toEqualTypeOf<(shape: string) => number>();
assert.equal(matcher("a"), 1);
assert.equal(matcher("b"), 2);
assert.equal(matcher("true"), 1);
assert.equal(matcher("1"), 2);
});
test("getMatcherW factory rejects patterns outside its contract", () => {
test("getPrimitiveUnionMatcher: a broad universe is rejected", () => {
// Act / Assert — the calls below must not compile
// @ts-expect-error `string` is not a finite union of literals
getPrimitiveUnionMatcher<string>()({ a: () => 1 });
// @ts-expect-error `number` is not a finite union of literals
getPrimitiveUnionMatcher<number>()({ 1: () => 1 });
// @ts-expect-error a template literal is open
getPrimitiveUnionMatcher<`a${string}`>()({ a: () => 1 });
// @ts-expect-error a literal mixed with a template literal is open
getPrimitiveUnionMatcher<"a" | `x-${number}`>()({ a: () => 1 });
// @ts-expect-error `string | boolean` is open because of `string`
getPrimitiveUnionMatcher<string | boolean>()({ true: () => 1 });
// @ts-expect-error the widening factory rejects broad universes too
getPrimitiveUnionMatcherW<string>()({ a: () => 1 });
});
test("getPrimitiveUnionMatcher: a value/stringification collision is rejected", () => {
// Act / Assert — the calls below must not compile
// @ts-expect-error `true` collides with the string `"true"`
getPrimitiveUnionMatcher<"true" | true>()({ true: () => 1 });
// @ts-expect-error `false` collides with the string `"false"`
getPrimitiveUnionMatcher<"false" | false>()({ false: () => 1 });
// @ts-expect-error `null` collides with the string `"null"`
getPrimitiveUnionMatcher<"null" | null>()({ null: () => 1 });
// @ts-expect-error `undefined` collides with the string `"undefined"`
getPrimitiveUnionMatcher<"undefined" | undefined>()({
undefined: () => 1,
});
// @ts-expect-error the number `1` collides with the string `"1"`
getPrimitiveUnionMatcher<1 | "1">()({ 1: () => 1 });
});
test("getPrimitiveUnionMatcher: a collision is rejected beside legal members", () => {
// Act / Assert — the calls below must not compile; the legal members do not
// mask the collision. Each map is otherwise complete, so only the collision
// can make it invalid.
// @ts-expect-error `true` collides with `"true"` beside legal members
getPrimitiveUnionMatcher<"true" | true | "a" | 2>()({
true: () => 1,
a: () => 2,
2: () => 3,
});
// @ts-expect-error `false` collides with `"false"` beside legal members
getPrimitiveUnionMatcher<"false" | false | "a" | 2>()({
false: () => 1,
a: () => 2,
2: () => 3,
});
// @ts-expect-error `null` collides with `"null"` beside legal members
getPrimitiveUnionMatcher<"null" | null | "a" | 2>()({
null: () => 1,
a: () => 2,
2: () => 3,
});
// @ts-expect-error `undefined` collides with `"undefined"` beside legal members
getPrimitiveUnionMatcher<"undefined" | undefined | "a" | 2>()({
undefined: () => 1,
a: () => 2,
2: () => 3,
});
// @ts-expect-error `1` collides with `"1"` beside legal members
getPrimitiveUnionMatcher<1 | "1" | "a" | true>()({
1: () => 1,
a: () => 2,
true: () => 3,
});
});
test('getPrimitiveUnionMatcher: `"true"` and a bare `false` are legal together', () => {
// Arrange — `false` stringifies to `"false"`, so it does not collide with
// the string member `"true"`.
const factory = getPrimitiveUnionMatcher<"true" | false | "a" | 2>();
// Act
const matcher = factory({
true: (s) => {
expectTypeOf(s).toEqualTypeOf<"true">();
assert.equal(s, "true");
return 1;
},
false: (s) => {
expectTypeOf(s).toEqualTypeOf<false>();
assert.equal(s, false);
return 2;
},
a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">();
assert.equal(s, "a");
return 3;
},
2: (s) => {
expectTypeOf(s).toEqualTypeOf<2>();
assert.equal(s, 2);
return 4;
},
});
// Assert
assert.equal(matcher("true"), 1);
assert.equal(matcher(false), 2);
assert.equal(matcher("a"), 3);
assert.equal(matcher(2), 4);
});
test('getPrimitiveUnionMatcher: `"false"` and a bare `true` are legal together', () => {
// Arrange — `true` stringifies to `"true"`, not `"false"`.
const factory = getPrimitiveUnionMatcher<"false" | true | "a" | 2>();
// Act
const matcher = factory({
false: (s) => {
expectTypeOf(s).toEqualTypeOf<"false">();
assert.equal(s, "false");
return 1;
},
true: (s) => {
expectTypeOf(s).toEqualTypeOf<true>();
assert.equal(s, true);
return 2;
},
a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">();
assert.equal(s, "a");
return 3;
},
2: (s) => {
expectTypeOf(s).toEqualTypeOf<2>();
assert.equal(s, 2);
return 4;
},
});
// Assert
assert.equal(matcher("false"), 1);
assert.equal(matcher(true), 2);
assert.equal(matcher("a"), 3);
assert.equal(matcher(2), 4);
});
test('getPrimitiveUnionMatcher: `"null"` and a bare `undefined` are legal together', () => {
// Arrange — `undefined` stringifies to `"undefined"`, not `"null"`.
const factory = getPrimitiveUnionMatcher<"null" | undefined | "a" | 2>();
// Act
const matcher = factory({
null: (s) => {
expectTypeOf(s).toEqualTypeOf<"null">();
assert.equal(s, "null");
return 1;
},
undefined: (s) => {
expectTypeOf(s).toEqualTypeOf<undefined>();
assert.equal(s, undefined);
return 2;
},
a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">();
assert.equal(s, "a");
return 3;
},
2: (s) => {
expectTypeOf(s).toEqualTypeOf<2>();
assert.equal(s, 2);
return 4;
},
});
// Assert
assert.equal(matcher("null"), 1);
assert.equal(matcher(undefined), 2);
assert.equal(matcher("a"), 3);
assert.equal(matcher(2), 4);
});
test('getPrimitiveUnionMatcher: `"undefined"` and a bare `null` are legal together', () => {
// Arrange — `null` stringifies to `"null"`, not `"undefined"`.
const factory = getPrimitiveUnionMatcher<"undefined" | null | "a" | 2>();
// Act
const matcher = factory({
undefined: (s) => {
expectTypeOf(s).toEqualTypeOf<"undefined">();
assert.equal(s, "undefined");
return 1;
},
null: (s) => {
expectTypeOf(s).toEqualTypeOf<null>();
assert.equal(s, null);
return 2;
},
a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">();
assert.equal(s, "a");
return 3;
},
2: (s) => {
expectTypeOf(s).toEqualTypeOf<2>();
assert.equal(s, 2);
return 4;
},
});
// Assert
assert.equal(matcher("undefined"), 1);
assert.equal(matcher(null), 2);
assert.equal(matcher("a"), 3);
assert.equal(matcher(2), 4);
});
test('getPrimitiveUnionMatcher: `"1"` beside other numbers and strings is legal', () => {
// Arrange — the task's example: `"1"` is the string member, `2` and `3`
// are bare numbers, `"4"` is another string. No stringification lands on
// another member.
const factory = getPrimitiveUnionMatcher<"1" | 2 | 3 | "4">();
// Act
const matcher = factory({
"1": (s) => {
expectTypeOf(s).toEqualTypeOf<"1">();
assert.equal(s, "1");
return 1;
},
2: (s) => {
expectTypeOf(s).toEqualTypeOf<2>();
assert.equal(s, 2);
return 2;
},
3: (s) => {
expectTypeOf(s).toEqualTypeOf<3>();
assert.equal(s, 3);
return 3;
},
"4": (s) => {
expectTypeOf(s).toEqualTypeOf<"4">();
assert.equal(s, "4");
return 4;
},
});
// Assert
assert.equal(matcher("1"), 1);
assert.equal(matcher(2), 2);
assert.equal(matcher(3), 3);
assert.equal(matcher("4"), 4);
});
// The maximum legal universe needs one handler per member; the statement count
// is inherent to the case.
// oxlint-disable-next-line max-statements
test("getPrimitiveUnionMatcher: the maximum legal universe is supported", () => {
// Arrange — every Matchable kind, one representation per value, plus extra
// literals. No member's stringification is another member.
type Legal =
| "true"
| false
| null
| undefined
| "1"
| 2
| 3
| "a"
| "b"
| "4";
const factory = getPrimitiveUnionMatcher<Legal>();
// Act
const matcher = factory({
true: (s) => {
expectTypeOf(s).toEqualTypeOf<"true">();
assert.equal(s, "true");
return 1;
},
false: (s) => {
expectTypeOf(s).toEqualTypeOf<false>();
assert.equal(s, false);
return 2;
},
null: (s) => {
expectTypeOf(s).toEqualTypeOf<null>();
assert.equal(s, null);
return 3;
},
undefined: (s) => {
expectTypeOf(s).toEqualTypeOf<undefined>();
assert.equal(s, undefined);
return 4;
},
"1": (s) => {
expectTypeOf(s).toEqualTypeOf<"1">();
assert.equal(s, "1");
return 5;
},
2: (s) => {
expectTypeOf(s).toEqualTypeOf<2>();
assert.equal(s, 2);
return 6;
},
3: (s) => {
expectTypeOf(s).toEqualTypeOf<3>();
assert.equal(s, 3);
return 7;
},
a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">();
assert.equal(s, "a");
return 8;
},
b: (s) => {
expectTypeOf(s).toEqualTypeOf<"b">();
assert.equal(s, "b");
return 9;
},
"4": (s) => {
expectTypeOf(s).toEqualTypeOf<"4">();
assert.equal(s, "4");
return 10;
},
});
// Assert
expectTypeOf(matcher).toEqualTypeOf<(shape: Legal) => number>();
assert.equal(matcher("true"), 1);
assert.equal(matcher(false), 2);
assert.equal(matcher(null), 3);
assert.equal(matcher(undefined), 4);
assert.equal(matcher("1"), 5);
assert.equal(matcher(2), 6);
assert.equal(matcher(3), 7);
assert.equal(matcher("a"), 8);
assert.equal(matcher("b"), 9);
assert.equal(matcher("4"), 10);
});
test("getPrimitiveUnionMatcher: the maximum illegal universe is rejected", () => {
// Arrange — every value paired with its stringification.
type Illegal =
| "true"
| true
| "false"
| false
| "null"
| null
| "undefined"
| undefined
| "1"
| 1;
// Act / Assert — the call below must not compile
// @ts-expect-error every value collides with its stringification
getPrimitiveUnionMatcher<Illegal>()({
true: () => 1,
false: () => 2,
null: () => 3,
undefined: () => 4,
"1": () => 5,
});
});
test("getPrimitiveUnionMatcher: an open universe is rejected with a fallback too", () => {
// Act / Assert — the call below must not compile; a broad universe is
// unsupported whether or not a fallback is supplied.
// @ts-expect-error `string` is not a finite union of literals
getPrimitiveUnionMatcher<string>()({ a: () => 1 }, () => 2);
});
test("getPrimitiveUnionMatcherW factory rejects patterns outside its contract", () => {
// Arrange
const factory = getMatcherW<"x" | "y">();
const factory = getPrimitiveUnionMatcherW<"x" | "y">();
// Act / Assert — the calls below must not compile
factory({
@@ -533,35 +906,28 @@ test("getMatcherW factory rejects patterns outside its contract", () => {
// Dispatch — runtime behavior
// ============================================================================
test("getMatcher: an unhandled shape throws without a fallback", () => {
// Arrange — an open universe types its handler map as an index signature,
// so the type system cannot prove the runtime map is exhaustive.
const handlers: Record<string, (shape: unknown) => number> = {
a: (shape) => {
// The raw shape reaches the handler, not the property key's text.
assert.equal(shape, "a");
return 1;
},
};
const matcher = getMatcher<string>()(handlers);
test("getPrimitiveUnionMatcher: an unhandled shape throws without a fallback", () => {
// Arrange — a finite universe is exhaustive through the typed API, so the
// defensive throw is reachable only by widening the factory and dropping a
// handler.
const bypass: Function = getPrimitiveUnionMatcher<"a" | "b">();
const matcher: (shape: "a" | "b") => number = bypass({ a: () => 1 });
// Act / Assert
assert.equal(matcher("a"), 1);
assert.throws(() => matcher("b"), /Unhandled shape: b/);
});
test("getMatcher: an unhandled boolean shape throws without a fallback", () => {
// Arrange — an `string | boolean` universe widens its handler map to an
// index signature, so the runtime map's exhaustiveness is not provable.
const handlers: Record<string, (shape: unknown) => number> = {
true: (shape) => {
// `true` indexes the map as the property `"true"`, but the handler
// is still called with the boolean.
test("getPrimitiveUnionMatcher: an unhandled boolean shape throws without a fallback", () => {
// Arrange — as above; `true` indexes the map as the property `"true"`, but
// the handler is still called with the boolean.
const bypass: Function = getPrimitiveUnionMatcher<true | false>();
const matcher: (shape: boolean) => number = bypass({
true: (shape: boolean) => {
assert.equal(shape, true);
return 1;
},
};
const matcher = getMatcher<string | boolean>()(handlers);
});
// Act / Assert
assert.equal(matcher(true), 1);
@@ -584,7 +950,7 @@ const UNIVERSE = `"a" | "b" | "c"`;
// documents, project membership), so they pass in any order.
interface LabelsProbe {
readonly name: string;
readonly factory: "getMatcher" | "getMatcherW";
readonly factory: "getPrimitiveUnionMatcher" | "getPrimitiveUnionMatcherW";
readonly body: string;
readonly universe?: string;
readonly tail?: string;
@@ -616,12 +982,12 @@ const labelsFor = ({
test("autocomplete: an exhaustive pattern requires the universe", () => {
// Arrange
const name = "getMatcher_fresh";
const name = "getPrimitiveUnionMatcher_fresh";
// Act
const labels = labelsFor({
name,
factory: "getMatcher",
factory: "getPrimitiveUnionMatcher",
body: " /*COMPLETE*/",
});
@@ -633,12 +999,12 @@ test("autocomplete: an exhaustive pattern requires the universe", () => {
test("autocomplete: handled keys drop out of the popup", () => {
// Arrange
const name = "getMatcher_after_key";
const name = "getPrimitiveUnionMatcher_after_key";
// Act
const labels = labelsFor({
name,
factory: "getMatcher",
factory: "getPrimitiveUnionMatcher",
body: " a: () => 1,\n /*COMPLETE*/",
});
@@ -650,12 +1016,12 @@ test("autocomplete: handled keys drop out of the popup", () => {
test("autocomplete: a fallback makes the remaining keys optional", () => {
// Arrange
const name = "getMatcher_with_fallback";
const name = "getPrimitiveUnionMatcher_with_fallback";
// Act
const labels = labelsFor({
name,
factory: "getMatcher",
factory: "getPrimitiveUnionMatcher",
body: " /*COMPLETE*/",
tail: ", () => 0",
});
@@ -668,12 +1034,12 @@ test("autocomplete: a fallback makes the remaining keys optional", () => {
test("autocomplete: with a fallback, handled keys stay optional", () => {
// Arrange
const name = "getMatcher_with_fallback_after_key";
const name = "getPrimitiveUnionMatcher_with_fallback_after_key";
// Act
const labels = labelsFor({
name,
factory: "getMatcher",
factory: "getPrimitiveUnionMatcher",
body: " a: () => 1,\n /*COMPLETE*/",
tail: ", () => 0",
});
@@ -684,14 +1050,14 @@ test("autocomplete: with a fallback, handled keys stay optional", () => {
});
});
test("autocomplete: `getMatcherW` offers the same popup as `getMatcher`", () => {
test("autocomplete: `getPrimitiveUnionMatcherW` offers the same popup as `getPrimitiveUnionMatcher`", () => {
// Arrange
const name = "getMatcherW_fresh";
const name = "getPrimitiveUnionMatcherW_fresh";
// Act
const labels = labelsFor({
name,
factory: "getMatcherW",
factory: "getPrimitiveUnionMatcherW",
body: " /*COMPLETE*/",
});
@@ -701,14 +1067,14 @@ test("autocomplete: `getMatcherW` offers the same popup as `getMatcher`", () =>
});
});
test("autocomplete: `getMatcherW` also offers optional keys with a fallback", () => {
test("autocomplete: `getPrimitiveUnionMatcherW` also offers optional keys with a fallback", () => {
// Arrange
const name = "getMatcherW_with_fallback";
const name = "getPrimitiveUnionMatcherW_with_fallback";
// Act
const labels = labelsFor({
name,
factory: "getMatcherW",
factory: "getPrimitiveUnionMatcherW",
body: " /*COMPLETE*/",
tail: ", () => 0",
});
@@ -721,12 +1087,12 @@ test("autocomplete: `getMatcherW` also offers optional keys with a fallback", ()
test("autocomplete: boolean and nullish keys are offered by name", () => {
// Arrange
const name = "getMatcher_boolean_nullish";
const name = "getPrimitiveUnionMatcher_boolean_nullish";
// Act
const labels = labelsFor({
name,
factory: "getMatcher",
factory: "getPrimitiveUnionMatcher",
universe: "boolean | null | undefined",
body: " /*COMPLETE*/",
});
@@ -739,12 +1105,12 @@ test("autocomplete: boolean and nullish keys are offered by name", () => {
test("autocomplete: a handled boolean literal drops out of the popup", () => {
// Arrange
const name = "getMatcher_boolean_after_key";
const name = "getPrimitiveUnionMatcher_boolean_after_key";
// Act
const labels = labelsFor({
name,
factory: "getMatcher",
factory: "getPrimitiveUnionMatcher",
universe: "boolean | null | undefined",
body: " true: () => 1,\n /*COMPLETE*/",
});
+69 -58
View File
@@ -1,52 +1,28 @@
import type { Exact, ValueOf } from "type-fest";
import type { Exact } from "type-fest";
type UnaryFn<T, R> = (shape: T) => R;
// The primitive universe a matcher can discriminate. `boolean` is admitted as
// the pair `true | false`; see README § Caveats for the unsupported members.
type Matchable = string | number | boolean | null | undefined;
// `boolean`, `null` and `undefined` cannot be property keys, so a mapped type
// over the universe keys each non-key member by its stringification. `Param`
// inverts that projection, so a handler callback still receives the *real*
// member (`true`, not `"true"`) — see README § Caveats for the limits.
type PatternKey<T> = T extends boolean
? T extends true
? "true"
: "false"
: T extends null
? "null"
: T extends undefined
? "undefined"
: T;
type PatternParam<K> = K extends "true"
? true
: K extends "false"
? false
: K extends "null"
? null
: K extends "undefined"
? undefined
: K;
// `Extract` drops optional handlers (`undefined`) so `PatternReturns` also works
// when `P`'s constraint has optional keys.
type PatternReturns<P> = ReturnType<
Extract<ValueOf<P>, (...args: never[]) => unknown>
>;
import type {
HandlerMap,
Matchable,
Member,
PatternKey,
PatternReturns,
RedundantFallback,
UnaryFn,
UniverseGate,
} from "./matcher-shared.ts";
type Handlers<T extends Matchable, R> = {
[K in PatternKey<T>]: UnaryFn<PatternParam<K>, R>;
[K in PatternKey<T>]: UnaryFn<Member<T, K>, R>;
};
// The fallback is a *second argument*, not a property of the handler map,
// because its parameter is the remainder `Exclude<T, keyof Handled>` and TypeScript
// because its parameter is the remainder `Exclude<T, Member<T, keyof Handled>>` and TypeScript
// fixes a property's contextual type before it infers its sibling keys. A later
// argument, by contrast, is contextually typed from inference on an earlier
// one, so the split is what makes the remainder expressible at all.
// See development/library.md.
type Fallback<T extends Matchable, Handled, R> = UnaryFn<
Exclude<T, PatternParam<keyof Handled>>,
Exclude<T, Member<T, keyof Handled>>,
R
>;
@@ -58,9 +34,6 @@ type Fallback<T extends Matchable, Handled, R> = UnaryFn<
// failure is reported on the argument that inferred `Handled` (the handler
// map), so the required property is spelled as the message instead of relying
// on its position. See development/library.md.
interface RedundantFallback {
readonly "every case is already handled, so the fallback is redundant": never;
}
type MustBePartial<T extends Matchable, Handled> =
PatternKey<T> extends keyof Handled ? RedundantFallback : unknown;
@@ -68,43 +41,37 @@ type MustBePartial<T extends Matchable, Handled> =
// so `Exact` restores it for the generic forms: a handler map can otherwise
// carry keys outside `T`.
// oxlint-disable typescript/unified-signatures
// Strict returns: one common `R`. Overload order is load-bearing:
// #1 Handlers (first) -> the exhaustive form and the autocomplete popup
// #2 Fallback (last) -> accepts a partial handler map plus a fallback
interface MatcherStrict<T extends Matchable> {
<R>(handlers: Handlers<T, R>): UnaryFn<T, R>;
interface PrimitiveUnionMatcherStrict<T extends Matchable> {
<R>(handlers: Handlers<T, R> & UniverseGate<T>): UnaryFn<T, R>;
<
R,
Handled extends Exact<Partial<Handlers<T, R>>, Handled> &
MustBePartial<T, Handled>,
>(
handlers: Handled & Partial<Handlers<T, R>>,
fallback: Fallback<T, Handled, R>,
handlers: Handled & Partial<Handlers<T, R>> & UniverseGate<T>,
fallback: Fallback<T, Handled, R> & UniverseGate<T>,
): UnaryFn<T, R>;
}
// oxlint-enable typescript/unified-signatures
// Widened returns: the union of every handler's return type. `P` is inferred
// from the whole handler map, whose closed constraint supplies the
// contextual/autocomplete type.
// oxlint-disable typescript/unified-signatures
interface MatcherWidening<T extends Matchable> {
interface PrimitiveUnionMatcherWidening<T extends Matchable> {
<P extends Exact<Handlers<T, unknown>, P>>(
handlers: P,
handlers: P & UniverseGate<T>,
): UnaryFn<T, PatternReturns<P>>;
<
R,
Handled extends Exact<Partial<Handlers<T, unknown>>, Handled> &
MustBePartial<T, Handled>,
>(
handlers: Handled,
fallback: Fallback<T, Handled, R>,
handlers: Handled & UniverseGate<T>,
fallback: Fallback<T, Handled, R> & UniverseGate<T>,
): UnaryFn<T, PatternReturns<Handled> | R>;
}
// oxlint-enable typescript/unified-signatures
type HandlerMap = Record<string | number, UnaryFn<never, unknown> | undefined>;
const dispatch =
(handlers: HandlerMap, fallback?: UnaryFn<never, unknown>) =>
@@ -129,6 +96,50 @@ const dispatch =
shape as never,
);
export const getMatcher = <T extends Matchable>(): MatcherStrict<T> => dispatch;
export const getMatcherW = <T extends Matchable>(): MatcherWidening<T> =>
dispatch;
/**
* 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 = <
T extends Matchable,
>(): 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 = <
T extends Matchable,
>(): PrimitiveUnionMatcherWidening<T> => dispatch;
File diff suppressed because it is too large. Load diff
+207
View File
@@ -0,0 +1,207 @@
import type { Exact, SetRequired, UnknownRecord } from "type-fest";
import type {
HandlerMap,
Matchable,
Member,
PatternKey,
PatternReturns,
RedundantFallback,
UnaryFn,
UniverseGate,
} from "./matcher-shared.ts";
// A tagged union is discriminated by one property whose values are the tags (a
// `Matchable`). `string` and `number` tags key a handler map directly;
// `boolean`, `null` and `undefined` are admitted too but are not property keys,
// so they go through the `PatternKey` projection. `symbol` has no literal syntax
// to write a handler under, and `bigint` is not a property key.
// The discriminant values of `T` under `K`. `Extract` keeps the finite literal
// tags and leaves a widened `string`/`number` as itself; a broad tag is then
// rejected by the universe gate.
type Tags<T extends object, K extends keyof T> = Extract<T[K], Matchable>;
// The keys of `T` that can act as a discriminant. `getTaggedUnionMatcher<T>()`
// accepts only these, so the factory rejects a key whose values are not tags.
type Discriminated<T extends object> = {
[K in keyof T]: T[K] extends Matchable ? K : never;
}[keyof T];
// The member(s) of `T` narrowed to the tag(s) `V`. Distributes over `T`, so a
// duplicated tag maps to a union of members rather than silently dropping one.
// A member whose `K` cannot take `V` drops out; the rest have `K` narrowed to
// `Extract<T[K], V>`. `T[K] extends V` keeps an exact member (a discriminated
// union's declared interface) untouched, so only a property that is itself a
// union — or optional — goes through the mapped form. `SetRequired` makes `K`
// required unless `V` admits `undefined`: a defined tag yields `{ type: "x" }`,
// while the `undefined` tag keeps `{ type?: never }` (absence) or
// `{ type?: undefined }` when the property admits an explicit `undefined`.
// Keyed by `PatternKey`, so `boolean`/`null`/`undefined` tags can key a mapped
// type; `Member` inverts the projection against the tag set to recover the tag.
type Narrowed<T extends object, K extends keyof T, V> = T extends object
? [Extract<T[K], V>] extends [never]
? never
: T[K] extends V
? T
: undefined extends V
? { [P in keyof T]: P extends K ? Extract<T[P], V> : T[P] }
: SetRequired<
{ [P in keyof T]: P extends K ? Extract<T[P], V> : T[P] },
K
>
: never;
type MapTaggedUnion<T extends object, K extends keyof T> = {
[P in PatternKey<Tags<T, K>>]: Narrowed<T, K, Member<Tags<T, K>, P>>;
};
type Handlers<T extends object, K extends keyof T, R> = {
[P in PatternKey<Tags<T, K>>]: UnaryFn<MapTaggedUnion<T, K>[P], R>;
};
// The tag values whose `PatternKey` is handled.
type HandledTags<T extends object, K extends keyof T, Handled> = Member<
Tags<T, K>,
keyof Handled
>;
// The fallback is a *second argument*, not a property of the handler map, so
// its parameter can be the remainder the map left uncovered: the members
// narrowed to the tags the map did not handle. See development/library.md.
type Fallback<T extends object, K extends keyof T, Handled, R> = UnaryFn<
Narrowed<T, K, Exclude<Tags<T, K>, HandledTags<T, K, Handled>>>,
R
>;
// A fallback is redundant once the handler map covers every tag of `T`. Folded
// into `Handled`'s own (self-referential) constraint so it is checked *after*
// inference; see the primitive-union matcher for why a conditional in the fallback's
// parameter is evaluated too early.
type MustBePartial<T extends object, K extends keyof T, Handled> =
PatternKey<Tags<T, K>> extends keyof Handled ? RedundantFallback : unknown;
// TypeScript does not apply the excess-property check to a generic constraint,
// so `Exact` restores it for the generic forms.
// Strict returns: one common `R`. Overload order is load-bearing:
// #1 Handlers (first) -> the exhaustive form and the autocomplete popup
// #2 Fallback (last) -> accepts a partial handler map plus a fallback
interface TaggedUnionMatcherStrict<T extends object, K extends keyof T> {
<R>(handlers: Handlers<T, K, R> & UniverseGate<Tags<T, K>>): UnaryFn<T, R>;
<
R,
Handled extends Exact<Partial<Handlers<T, K, R>>, Handled> &
MustBePartial<T, K, Handled>,
>(
handlers: Handled &
Partial<Handlers<T, K, R>> &
UniverseGate<Tags<T, K>>,
fallback: Fallback<T, K, Handled, R> & UniverseGate<Tags<T, K>>,
): UnaryFn<T, R>;
}
// Widened returns: the union of every handler's return type. `P` is inferred
// from the whole handler map, whose closed constraint supplies the
// contextual/autocomplete type.
interface TaggedUnionMatcherWidening<T extends object, K extends keyof T> {
<P extends Exact<Handlers<T, K, unknown>, P>>(
handlers: P & UniverseGate<Tags<T, K>>,
): UnaryFn<T, PatternReturns<P>>;
<
R,
Handled extends Exact<Partial<Handlers<T, K, unknown>>, Handled> &
MustBePartial<T, K, Handled>,
>(
handlers: Handled & UniverseGate<Tags<T, K>>,
fallback: Fallback<T, K, Handled, R> & UniverseGate<Tags<T, K>>,
): UnaryFn<T, PatternReturns<Handled> | R>;
}
// The key-taking step of the curried factory. Naming it lets the factory return
// `dispatch` directly, the tacit twin of the primitive-union factory's bare
// `=> dispatch`.
type TaggedUnionMatcherFactory<T extends object> = <K extends Discriminated<T>>(
k: K,
) => TaggedUnionMatcherStrict<T, K>;
type TaggedUnionMatcherWideningFactory<T extends object> = <
K extends Discriminated<T>,
>(
k: K,
) => TaggedUnionMatcherWidening<T, K>;
const dispatch =
(k: PropertyKey) =>
(handlers: HandlerMap, fallback?: UnaryFn<never, unknown>) =>
(shape: object): unknown => {
// `object` carries no index signature, so the read needs the assertion;
// the factory admits only keys whose values are tags, and the map keys
// them by `PatternKey`, so the result is narrowed to the map's key space.
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
const tag = (shape as UnknownRecord)[k] as string | number;
return (
handlers[tag] ??
fallback ??
(() => {
throw new Error(`Unhandled tag: ${String(tag)}`);
})
)(
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
shape as never,
);
};
/**
* 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 = <
T extends object,
>(): 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 = <
T extends object,
>(): TaggedUnionMatcherWideningFactory<T> => dispatch;
+2 -1
View File
@@ -8,5 +8,6 @@
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true
},
"include": ["src", "scripts"]
"include": ["src", "scripts"],
"exclude": ["src/doc-test"]
}