141 Commits
Author SHA1 Message Date
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
tmu a4b4618c9f 🚀 Release 0.5.0
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 30s
CI / maintain (push) Failing after 14s
CI / publish (push) Failing after 17s
2026-09-21 13:11:27 +00:00
tmu bf711cf9fc 🔀 Merge chore/test-handler-runtime-arguments into main 2026-09-21 13:09:10 +00:00
tmu 9e03c52473 ✅ Assert the shape each handler receives
A handler's `expectTypeOf(shape)` only proved the type the compiler inferred;
nothing observed the argument `dispatch` passed. Passing `String(shape)` at the
call site therefore kept the whole suite green while breaking every key whose
property name differs from its value (`true`, `null`, `1`).

Pair each handler expectation with a runtime assertion: `assert.equal` where a
handler runs for one shape, the disjunction over the set a `_` fallback accepts
where it runs for several. Where the exact value matters the fallback returns
its shape verbatim and the call site asserts it; a widening pattern absorbs the
remainder type into the return union, so the claim stays strict-free. The
mutation above now fails 10 of the 33 tests.

Rule: CONTRIBUTING.md, rationale: development/testing.md § Handler arguments.
2026-09-21 12:52:58 +00:00
tmu aa6d71076c 🔀 Merge feature/boolean-null-undefined-patterns into main 2026-09-21 11:27:29 +00:00
tmu 80d498e4f4 ⚡ Index dispatch with the raw shape key
String(shape) defeats V8's numeric-key fast path (measured ~2x on
number-keyed dispatch). JS already coerces boolean/null/undefined to the
same property key, so assert shape to string | number and index directly.
The assertion is TS2538-only; the facade keeps it sound. Needs a source
no-unsafe-type-assertion disable, next to the existing one.
2026-09-21 11:23:14 +00:00
tmu 1ad19ba308 📝 Document matcher caveats in the README
State the end-user limits once, in README § Caveats: a member colliding
with its stringification, the unsupported symbol/bigint, and NaN/-0. Drop
the duplicated known-issue prose from development/library.md and the
source comment; link to the README instead.
2026-09-21 10:33:36 +00:00
tmu 16904440cf 📝 Place test-wide suppressions in the config
Drop the fixed no-floating-promises header exception from CONTRIBUTING.md
and AGENTS.md; agents must not add a source disable or edit .oxlintrc.json.
Record the split in tooling.md and testing.md: a one-site false positive is a
source disable, a file-class one lives in the test override.
2026-09-21 10:01:30 +00:00
tmu 9b7dec96e0 🔧 Move test lint disables to oxlint config
The no-floating-promises header was repeated at the top of every test
file. Centralize it, and the no-null suppression the new null/undefined
cases need, in the **/*.test.ts override.
2026-09-21 09:56:39 +00:00
tmu 45495fe2a8 ✨ Match boolean, null and undefined
Extend the matcher universe beyond string | number so a union can carry
boolean, null and undefined members. true|false, null and undefined are
not property keys, so handler keys are their stringification while the
callback still receives the real member. Symbols are rejected (a brand
is compile-time only) and bigint is not a property key.
2026-09-21 09:44:44 +00:00
tmu c32fe08c73 🔀 Merge chore/spike-redundant-fallback into main
CI / release-gate (push) Successful in 3s
CI / build (push) Successful in 24s
CI / publish (push) Skipped
CI / maintain (push) Failing after 16s
2026-09-20 22:08:35 +00:00
tmu f27966e803 ✨ Reject a fallback for an exhaustive map
A fallback paired with a handler map that already covers `T` was still
accepted, even though its remainder is empty. Fold the guard into
`Handled`'s own (F-bounded) constraint so it is checked after inference;
a conditional in the fallback parameter is evaluated while `Handled` is
still its constraint and rejects every partial map whose handler callbacks
need contextual typing.
2026-09-20 22:05:40 +00:00
tmu 7e7f716424 🚀 Release 0.4.0
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 30s
CI / maintain (push) Failing after 14s
CI / publish (push) Failing after 16s
2026-09-20 21:46:32 +00:00
tmu 62db7de537 🔀 Merge chore/unify-task-branching into main 2026-09-20 21:44:21 +00:00
tmu ff823b3cba 📝 Route every task through the full branching model
Drop the leaf-task exception in AGENTS.md: a task without subtasks was implemented on the current branch, bypassing create:branch / create:finish. Every task now opens and closes a branch, so the clean-tree / current-main / green-baseline preconditions and the post-merge verify always apply.
2026-09-20 21:43:46 +00:00
tmu eeb831b717 🔀 Merge feature/fallback-remainder into main
CI / release-gate (push) Successful in 3s
CI / build (push) Successful in 24s
CI / publish (push) Skipped
CI / maintain (push) Failing after 15s
2026-09-19 00:02:23 +00:00
tmu ae7ffb6fa2 📝 Document the two-argument fallback
Rewrite development/library.md around the new shape: the fallback is a second
argument (so it can receive the remainder), `R` needs an inference site in the
handler map, `Exact` restores the excess-property check, and the overload order
is load-bearing. Record this branch's rejected alternatives (single-object `_`,
currying, `this`/HKT, variance/`const`/`NoInfer`/brands) and the redundant-
fallback known issue. Add the changelog note and check off the backlog task.
2026-09-18 23:52:47 +00:00
tmu 0d6a3f1b9b ✅ Cover the dispatch throw
An open universe (`getMatcher<string>()`) types its handler map as an index
signature, so a runtime map with fewer keys still satisfies the exhaustive
overload. Calling the matcher with a missing shape reaches the dispatch guard
and throws — no cast needed.
2026-09-18 23:48:59 +00:00
tmu 8615723c64 ✅ Cover fallback autocompletes
When a fallback argument is present, the second overload supplies the
contextual type, so the handler map popup offers optional keys (`a? b? c?`)
and handled keys stay optional (`b? c?`). The exhaustive one-argument popup
is unchanged. Covered for both factories.
2026-09-18 23:44:51 +00:00
tmu 935e82d205 ✅ Reject a mismatched strict fallback
The factory-contract test now also covers a fallback whose return does not
fit the common return of the handler map — a `number` handler with a
`string` fallback — which `getMatcherW` accepts as `number | string`.
2026-09-18 23:37:44 +00:00
tmu c7223dbe69 🐛 Infer strict return from the handler map
The fallback overload could not infer `R` from the handlers: `R` appeared
only inside the `Exact` constraint, which is not an inference site, so with
inferred handler params it collapsed to `unknown` and the matcher silently
returned `UnaryFn<T, unknown>`. Adding `Partial<Handlers<T, R>>` to the
handler parameter gives `R` an inference site, so the common return is
inferred from the handlers and the fallback alike.

Tests drop their explicit handler return annotations: real handlers are short
and unannotated, and the common type is now inferred from them.
2026-09-18 23:33:34 +00:00
tmu 2d3577716d ✨ Narrow the fallback to unhandled keys
The fallback is now the second factory argument — `(handlers, (s) => …)` —
so its parameter is `Exclude<T, keyof handlers>`. A property's contextual
type is fixed before TypeScript infers its sibling keys, so the remainder is
not expressible while the fallback sits in the handler map; a later argument
is contextually typed from inference on an earlier one.

Both factories take the new shape; the tests and autocomplete probes follow.
The generic overloads guard excess keys with type-fest's `Exact`, so the
error lands on the offending property. A redundant fallback (a full handler
map plus one) is still accepted — the guard for it does not survive inference
and is left to its backlog task.
2026-09-18 23:08:16 +00:00
tmu cc88c07961 🚀 Release 0.3.0
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 29s
CI / maintain (push) Failing after 15s
CI / publish (push) Failing after 18s
2026-09-18 07:51:16 +00:00
tmu 62c37e6d89 🔀 Merge chore/protocol-backed-lsp-helper into main
CI / release-gate (push) Successful in 3s
CI / build (push) Successful in 24s
CI / publish (push) Skipped
CI / maintain (push) Failing after 14s
2026-09-18 07:45:22 +00:00
tmu 947eac8089 ⏪ Revert the LSP notes in testing.md
The protocol cross-link and the TS-internal shutdown-race explanation were
too detailed for the category doc. The helper comment keeps a short,
self-contained note on the stdin-EOF workaround.
2026-09-18 07:43:36 +00:00
tmu eea2fbb17d 🚨 Silence helper oxlint warnings
The completion helper is test-only (excluded from dist/), so the
style-only no-magic-numbers and unicorn/no-null warnings are tolerable
here. A file-level disable keeps them out of check:oxlint without touching
the project config.
2026-09-18 07:38:13 +00:00
tmu b278a153d3 📝 Check off the LSP shutdown bug
Record the cause (Session.updateWatches raced against exit) and the
stdin-EOF fix now that npm run verify is green with no context-canceled
output.
2026-09-18 07:31:10 +00:00
tmu 8b73143740 🐛 Stop the LSP shutdown context-canceled log
The TS 7 Go server races Session.updateWatches against the exit
notification; handleExit returns io.EOF, the background context is
cancelled, and the bare error is flushed to stderr once the outgoing
queue closes (server.go / logger.go). Send shutdown and close stdin
instead: EOF makes the server exit cleanly (code 0, no output), with the
kill kept as a fallback. testing.md records the cause.
2026-09-18 07:31:04 +00:00
tmu 13497d1df8 📝 Check off the LSP helper task
npm run verify is green: 23 tests pass, check:tsc, check:oxlint, check:oxfmt
and check:cspell clean with the protocol-backed helper.
2026-09-18 07:17:19 +00:00
tmu 998c4bfd80 📝 Document the protocol-backed LSP helper
Note that the autocomplete helper drives the server through
vscode-languageserver-protocol, cross-link the tooling decision, and replace
the obsolete string-id known issue with the benign context-canceled stderr
the Go server emits on shutdown.
2026-09-18 07:16:45 +00:00
tmu 9f56d7ae8e ♻️ Back LSP helper with protocol library
Replace the hand-rolled JSON-RPC client (framing, pending map, reply
dispatch) with createMessageConnection and typed requests from
vscode-languageserver-protocol. A catch-all onRequest answers the server
requests the old client replied to, and CompletionList/CompletionItem
replace the ad-hoc shape guards. Public LspSession API is unchanged.
2026-09-18 07:16:25 +00:00
tmu 94ccc16963 ➕ Add the LSP protocol dependency
The autocomplete helper drives tsc --lsp --stdio; vscode-languageserver-protocol
supplies the transport (re-exported vscode-jsonrpc/node) and the typed LSP
requests, replacing the hand-rolled JSON-RPC client. Record the choice and
the rejected alternatives in tooling.md.
2026-09-18 07:14:45 +00:00
tmu 0b97632a7c 📝 Track protocol-backed LSP helper
The autocomplete helper still carries a hand-rolled JSON-RPC client
(Content-Length framing, a pending-request map, server-request replies).
vscode-languageserver-protocol provides all of it plus typed requests, so
track the move as a task with subtasks.
2026-09-18 07:14:18 +00:00
tmu 6f87c2f3b5 🔀 Merge chore/adopt-three-overload-matcher into main 2026-09-17 21:56:41 +00:00
tmu 88159296d4 📝 Drop the stale scripts test glob from testing.md
No tests live under scripts/, and every test script (test, test:unit, test:ci,
watch:test) globs only src/**/*.test.ts, so state that. The scripts/**
oxlint scope it was confused with still exists and carries the same
import/no-nodejs-modules exception as the test-helper scope.
2026-09-17 21:55:41 +00:00
tmu 800c11139d ♻️ Name the matcher factories getMatcher / getMatcherW
Drop the strict/widened aliases from src/index.ts so the factories are
exported under their own names, and align the type tests, the autocomplete
probe (which now imports the public surface from ./index.ts) and library.md
with them.

Also correct the widened-overload comment: the excess-property check does not
apply to a generic P, so MatcherWidening's keyof guard — not the closed
constraint — is what rejects keys outside T/_, as library.md already
documented.
2026-09-17 21:48:54 +00:00
tmu 05fad0fcd6 📝 Record the autocomplete and matcher-design decisions
The matcher is now two three-overload factories; library.md documents why
the union merge, inferred universe, conditional RequireKeys and cases-first
paths were rejected, and lists the two open issues (the fallback sees all of
T; a redundant _ is still accepted). testing.md records the language server
as the autocomplete oracle, and CONTRIBUTING points the exception at the
matcher's own test file.

The backlog marks the design-doc and autocomplete groundwork done alongside
the adoption.
2026-09-17 20:59:28 +00:00
tmu 58d127da4e ✅ Test the LSP completion helper against the server
The helper's own contract — marker handling, requested position, label
extraction and the rejection when the marker is absent — is asserted against
in-memory documents whose contextual types are written inline, so the helper
is tested without coupling to the library's code.
2026-09-17 20:59:19 +00:00
tmu 165bd9e3df ♻️ Adopt the three-overload matcher
strict/widened each take the exhaustive/fallback pattern shape in three
overloads ordered ExhaustiveLoose -> Fallback -> Handlers. TypeScript reads
the first overload for the object-literal popup (_?, a, b) and the last for
the missing-key error, so autocomplete and the error message are tuned
independently. The fallback is a pattern shape, not a factory, so the four
getPrimitiveUnionMatcher* factories collapse to two.

src/index.ts re-exports the two factories; the prototype scratch files that
explored the alternatives are removed.
2026-09-17 20:59:09 +00:00
tmu 02634f41de 🔀 Merge chore/test-util-home into main 2026-09-17 13:33:41 +00:00
tmu 9da5b5dbf2 📝 Note the test-helper home in the changelog 2026-09-17 13:23:36 +00:00
tmu dc57529f0f 📝 Correct the upstream claim in branch.sh
`main` tracks `origin` (ssh); the comment claiming it tracked an
https companion remote was stale.
2026-09-17 13:23:34 +00:00
tmu 484e5c8ca5 📝 Allow the floating-promise header in test files
Agents may add the fixed file-level typescript/no-floating-promises
oxlint-disable header at the top of a `*.test.ts` file — the
expectTypeOf() misidentification is a documented false positive
(development/testing.md § Known issues) and the await/void workarounds
collide with eslint/no-void. Every other suppression stays the human
last resort it was.
2026-09-17 13:23:32 +00:00
tmu c51e32d6a6 📝 Record the test-helper home decision
development/testing.md § Test helpers: why name beats path under
import/no-relative-parent-imports, why the key needs `#`, why the
`__tests__` folder name, and what was rejected (flat placement, tsconfig
paths, rule exemptions).
2026-09-17 13:23:29 +00:00
tmu 9e5522c131 🚚 Home test helpers behind #test-utils/*
src/util/__tests__/ holds shared test helpers, reached from anywhere
in the source tree as `#test-utils/<name>.ts` through
package.json#imports. A bare `~/…` is not a valid imports key — Node
requires `#` — and a direction-free specifier keeps
import/no-relative-parent-imports from ever firing. The `__tests__`
folder name is the pattern tsconfig.build.json already excludes, so
helpers can never ship. Lint scoping: import/no-nodejs-modules off for
the folder (the probe spawns a language server) and
typescript/promise-function-async off to match its promise-returning
style. The LSP probe moves in from scripts/; a smoke test pins the
specifier's resolution and typing without starting a server.
2026-09-17 13:23:27 +00:00
tmu ee146ce350 🚀 Release 0.2.0
CI / release-gate (push) Successful in 3s
CI / build (push) Successful in 24s
CI / maintain (push) Failing after 15s
CI / publish (push) Failing after 17s
2026-09-16 22:07:16 +00:00
tmu 33c6d9dc8d 🔀 Merge chore/remove-example-code into main 2026-09-16 22:06:17 +00:00
tmu 65e885989b 📝 Backlog the README walkthrough restoration
The example-code removal stripped the synopsis and examples with the
template modules; track bringing them back around the real API, with the
previous section order recorded so its placement is not re-guessed.
2026-09-16 22:05:05 +00:00
tmu 16092350b8 📝 Check off example-code removal in backlog
Note the finished work under [Unreleased] and mark the task and its
subtasks done; retarget the v1.0 coverage tasks at the surviving
primitive module.
2026-09-16 21:58:14 +00:00
tmu a3eb6183af 🔥 Remove the match and pattern example modules
They were the template's demo API, not the library's real surface. Drop
them, their README walkthrough, and the tooling note that cited their
disable directives, and point index.ts at the primitive matchers that
remain.
2026-09-16 21:57:55 +00:00
tmu ce3d618757 🔥 Remove the example index spec
The spec only exercised the template's match/P example API. Removing it
first lets the modules and exports it imports go next without leaving a
dangling reference.
2026-09-16 21:57:42 +00:00
tmu 73e5bc0093 🔀 Merge chore/straighten-oxc-rules into main
CI / release-gate (push) Successful in 3s
CI / build (push) Successful in 23s
CI / publish (push) Skipped
CI / maintain (push) Failing after 14s
2026-09-16 21:50:14 +00:00
tmu 08513b36a9 📝 Check off straighten-oxc-rules in backlog
Note the finished work under [Unreleased] and mark the task and its
subtasks done.
2026-09-16 21:46:04 +00:00
tmu 068d6b4998 ♻️ Use line comments in primitive matchers
Replace the three `/* */` prose blocks with `//` line comments, matching
the rest of the codebase and the comment style the oxlint config now
allows.
2026-09-16 21:46:00 +00:00
tmu fa0b7f2e84 🔧 Allow ternaries and lowercase comments
Turn off `eslint/no-ternary` and `eslint/capitalized-comments` in the
project config: both encode a style the project rejects, so they belong in
the config's rule map rather than a per-site disable.

Scope the adjacent `oxlint-disable` decision to false positives and
record why a rejected stylistic rule is turned off project-wide.
2026-09-16 21:45:54 +00:00
tmu 71ecd508b7 🔀 Merge feature/primitve-pattern-matching into main 2026-09-16 21:40:57 +00:00
tmu c13bc2f408 📝 Document why any is retained in primitive matchers
Rewriting without any was evaluated; the alternatives were more
complex than the current solution. Record the rationale next to the
two oxlint-disable sites so the suppression is not re-litigated.
2026-09-16 21:39:46 +00:00
tmu 8e2691e6aa 📝 Will add backlog items found while working 2026-09-16 20:43:01 +00:00
tmu 16650b07e3 📝 Backlog primitive literal coverage questions 2026-09-16 20:01:20 +00:00
tmu a0f0894339 ✅ Spec mixed string and numeric union keys 2026-09-16 20:01:12 +00:00
tmu ed71929365 ✅ Check matcher handler params
The primitive union matchers invoke every handler with the matched
literal, but the specs wrote their handlers with zero parameters, so
the passed value and its inferred key-literal type went untested.

Declare the parameter on each such handler and pin it with
expectTypeOf, matching the style the two already-correct handlers used.
The `_` fallbacks assert the whole union rather than a single literal.
Return-type expectations are unchanged, so the W / non-W widening
distinctions are still covered.
2026-09-16 19:29:04 +00:00
tmu cce030b8e5 🐛 Type partial W matcher without an any cast
getPrimitiveUnionMatcherPartialW was declared with the intended
P-inferring signature but assigned via `as any`, hiding that the
declared parameter was not assignable to the implementation's. The
union in PatternPrimitiveUnionPartial makes the `_` arm demand
`_ ∈ keyof P`, which the exhaustive arm cannot prove.

Intersect the inference hook Simplify<P> with the implementation's
parameter shape, R pinned to PatternReturns<P>, so the assignment
type-checks while callers keep inferring P from the argument.

Add a spec for the exhaustive (no `_`) pattern.
2026-09-16 19:10:40 +00:00
tmu 2ff67801e0 ✨ Add VS Code node:test debugging and runner config
launch.json: 'Debug current test file' F5 config running
node --test --strip-types on the active file.

settings.json: register .ts with the connor4312.nodejs-testing
extension via nodejs-testing.extensions, using --strip-types (the
repo pins node >=26 with native type stripping) instead of tsx, so
the extension discovers src/*.test.ts and shows Run/Debug lenses
above test() calls in the native Testing UI.

extensions.json: recommend connor4312.nodejs-testing; cspell.json:
allow 'connor' for the extension id.

.gitignore: un-ignore .vscode/launch.json so the config is shared.
2026-09-16 17:34:53 +00:00
tmu 62a5599ab3 📝 Require labeled AAA blocks in tests
Rule in CONTRIBUTING.md; decision, why and rejected unlabeled
ordering in development/testing.md.
2026-09-16 16:00:43 +00:00
tmu 34567856a5 ♻️ Label arrange-act-assert blocks in specs
Rework every test body into // Arrange / // Act / // Assert blocks
separated by blank lines: the factory is arranged once, the matcher is
built from it in a single act, and all type/runtime checks sink to the
end. index.test.ts adopts the same shape.

Also migrate off expect-type's deprecated toMatchTypeOf: the checks
are assignability tests, so toExtend is the faithful replacement.
2026-09-16 16:00:41 +00:00
tmu a25ac6d1e2 ✅ Spec primitive union matchers
Type-driven tests: every expectTypeOf pairs an assert, covering the
header matrix (exhaustive vs partial patterns, strict R vs widened
returns, string and numeric keys, _ fallback wiring).

Negative cases use Parameters<typeof factory>[0] assignability because
expect-type's .not.toBeCallableWith collapses to never on these generic
Simplify<>-wrapped signatures.

Same documented expectTypeOf floating-promise false positive as
index.test.ts, so the same file-level disable applies; testing.md
known-issue updated to list both files.
2026-09-16 14:52:30 +00:00
tmu 40bf652b97 ✨ Add primitive union pattern matchers
Curried matchers over string|number literal unions in four variants
(exhaustive/partial x strict/widened return inference), per the header
matrix in src/primitive.ts.

- Adds type-fest for Simplify/ValueOf.
- Deliberate oxlint escape hatches (as any dispatch) until a cast-free
  formulation lands; see the file comments.
2026-09-16 14:52:21 +00:00
tmu 1e13917cbe 🚀 Release 0.1.8
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 25s
CI / maintain (push) Successful in 15s
CI / publish (push) Failing after 15s
2026-09-16 09:01:49 +00:00
tmu 42281a0f10 🔀 Merge chore/upgrade-deps into main 2026-09-16 08:56:32 +00:00
tmu c15bc0153e 📝 Require concise prose in development/
Actionable sentence in CONTRIBUTING.md § Rules the tools don't enforce;
the why — humans skim, agents imitate the dominant style, so verbosity
compounds — in development/README.md § Decision blocks. Both sides in
one commit per the write-each-fact-once rule.
2026-09-16 08:00:36 +00:00
tmu ae319f6547 🔧 Ignore @types/node in maintain:outdated
DefinitelyTyped keeps the @types/node latest dist-tag on the LTS line,
so check-outdated compared the current-line types against a lower
version: a permanent "reverted" flag, scan exit 1, zero signal. Ignored
by package name; --types filtering and a version-scoped pin rejected
(rationale in development/tooling.md).
2026-09-16 07:55:25 +00:00
tmu 63e8edce79 ⬆️ Upgrade dependencies
@types/node to ^26.6.1 and cspell to ^10.3.2 — the only packages
check-outdated found behind the registry, both bumps within the existing
semver ranges. cspell 10.3.2 drops its transitive
fast-json-stable-stringify. npm run verify is green with no rule or
format fallout. maintain:outdated still flags @types/node as "reverted"
because DefinitelyTyped's latest dist-tag stays on the 22.x LTS series;
known false positive, advisory scan only.
2026-09-16 07:31:49 +00:00
40 changed files with 6361 additions and 829 deletions

No files matched your search

+7
View File
@@ -77,6 +77,13 @@ jobs:
- run: npm ci - run: npm ci
- run: npm run build - run: npm run build
- run: npm run check - run: npm run check
# Compile the prose examples into gitignored tests. Kept as an
# explicit step (not a `pretest:ci` hook) so it is visible in the
# job log and `test:ci` stays a plain command.
- run: npm run create:doc-tests
# Fails the build below 100% coverage on `src/` (`c8 --all --100`);
# the same run produces the report published below. See
# development/ci.md § Coverage threshold.
- run: npm run test:ci - run: npm run test:ci
# Publish this tag's coverage to the self-hosted pages server, # Publish this tag's coverage to the self-hosted pages server,
# served read-only at # served read-only at
+6 -1
View File
@@ -1,6 +1,10 @@
node_modules node_modules
dist dist
coverage coverage
# Generated doc-tests (scripts/create-doc-tests.ts); the folder is kept via .gitkeep
src/doc-test/__generated__/*.test.ts
!src/doc-test/__generated__/.gitkeep
*.log *.log
*.tsbuildinfo *.tsbuildinfo
*.local *.local
@@ -10,5 +14,6 @@ coverage
!.vscode/extensions.json !.vscode/extensions.json
!.vscode/settings.json !.vscode/settings.json
!.vscode/tasks.json !.vscode/tasks.json
!.vscode/launch.json
.idea .idea
.DS_Store .DS_Store
+13 -2
View File
@@ -13,6 +13,8 @@
"eslint/no-undefined": "off", "eslint/no-undefined": "off",
"eslint/sort-keys": "off", "eslint/sort-keys": "off",
"eslint/id-length": "off", "eslint/id-length": "off",
"eslint/capitalized-comments": "off",
"eslint/no-ternary": "off",
"import/no-named-export": "off", "import/no-named-export": "off",
"eslint/one-var": "off", "eslint/one-var": "off",
"import/group-exports": "off", "import/group-exports": "off",
@@ -20,7 +22,8 @@
"eslint/sort-imports": "off", "eslint/sort-imports": "off",
"import/consistent-type-specifier-style": "off", "import/consistent-type-specifier-style": "off",
"unicorn/prefer-export-from": "off", "unicorn/prefer-export-from": "off",
"typescript/method-signature-style": "off" "typescript/method-signature-style": "off",
"typescript/promise-function-async": "off"
}, },
"options": { "typeAware": true }, "options": { "typeAware": true },
"env": { "builtin": true, "es2024": true, "node": true }, "env": { "builtin": true, "es2024": true, "node": true },
@@ -31,7 +34,9 @@
"no-unused-expressions": "off", "no-unused-expressions": "off",
"no-empty-file": "off", "no-empty-file": "off",
"import/no-nodejs-modules": "off", "import/no-nodejs-modules": "off",
"eslint/no-magic-numbers": "off" "eslint/no-magic-numbers": "off",
"unicorn/no-null": "off",
"typescript/no-floating-promises": "off"
} }
}, },
{ {
@@ -39,6 +44,12 @@
"rules": { "rules": {
"import/no-nodejs-modules": "off" "import/no-nodejs-modules": "off"
} }
},
{
"files": ["src/util/__tests__/**"],
"rules": {
"import/no-nodejs-modules": "off"
}
} }
], ],
"ignorePatterns": ["dist", "node_modules", "coverage"] "ignorePatterns": ["dist", "node_modules", "coverage"]
+1
View File
@@ -1,5 +1,6 @@
{ {
"recommendations": [ "recommendations": [
"connor4312.nodejs-testing",
"oxc.oxc-vscode", "oxc.oxc-vscode",
"streetsidesoftware.code-spell-checker", "streetsidesoftware.code-spell-checker",
"typescriptteam.native-preview", "typescriptteam.native-preview",
+15
View File
@@ -0,0 +1,15 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug current test file",
"runtimeExecutable": "node",
"runtimeArgs": ["--test", "--strip-types"],
"args": ["${file}"],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal"
}
]
}
+10
View File
@@ -1,4 +1,14 @@
{ {
"nodejs-testing.extensions": [
{
"extensions": ["mjs", "cjs", "js"],
"parameters": []
},
{
"extensions": ["ts"],
"parameters": ["--strip-types"]
}
],
"[typescript]": { "[typescript]": {
"editor.defaultFormatter": "oxc.oxc-vscode", "editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true "editor.formatOnSave": true
+9 -7
View File
@@ -7,10 +7,11 @@ first-action facts. Do not restate evolving prose here — it will drift.
## First action ## First action
- Project: F#-style pattern matching for TypeScript/ESM. Node `>=26` (pinned via `.node-version`), ESM-only (no CommonJS shim). - Project: pattern matching for TypeScript/ESM. Node `>=26` (pinned via `.node-version`), ESM-only (no CommonJS shim).
- **While iterating:** `npm run test` (`check:tsc` + the unit suite) for fast feedback on the files you changed. - **While iterating:** `npm run test` (`check:tsc` + the unit suite) for fast feedback on the files you changed.
- **Optional code intelligence:** this repo installs `@spences10/pi-lsp` (pinned in `.pi/settings.json`) as a project-local pi extension. It talks to the repo's own TypeScript 7 via `tsc --lsp --stdio` and exposes **read-only** tools — `lsp_hover`, `lsp_definition`, `lsp_references`, `lsp_find_symbol`, `lsp_document_symbols`, `lsp_diagnostics(_many)`. 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. - **Definition of done — run this before you call the work finished:** `npm run verify`. If all green, commit. If red, look at the output, fix the root cause, and re-run.
- **Touched a `ts`-tagged fence in `README.md` / `CONTRIBUTING.md`?** Run `npm run create:doc-tests` first: it regenerates the gitignored tests under `src/doc-test/__generated__/`, formats and type-checks them, so `verify` executes the documented example. CI runs it in an explicit step before `test:ci`; the fast local tiers do not. See [development/docs.md](./development/docs.md).
- **On commit:** write a good message (see [CONTRIBUTING.md § Commit messages](./CONTRIBUTING.md#commit-messages)). Lefthook's pre-commit hook already runs the fast, offline, staged-file checks — don't run them by hand. If the hook fails on style, `npm run fix`, restage, recommit. - **On commit:** write a good message (see [CONTRIBUTING.md § Commit messages](./CONTRIBUTING.md#commit-messages)). Lefthook's pre-commit hook already runs the fast, offline, staged-file checks — don't run them by hand. If the hook fails on style, `npm run fix`, restage, recommit.
- **Document decisions where the next maintainer will look:** rationale, rejected alternatives and known issues go in `development/<category>.md` (see [development/README.md](./development/README.md)); the actionable rule stays in [CONTRIBUTING.md](./CONTRIBUTING.md) and links to it. Write each fact once — never copy the rule into `development/` or the reason into `CONTRIBUTING.md` — and change both in the same commit when a rule changes. - **Document decisions where the next maintainer will look:** rationale, rejected alternatives and known issues go in `development/<category>.md` (see [development/README.md](./development/README.md)); the actionable rule stays in [CONTRIBUTING.md](./CONTRIBUTING.md) and links to it. Write each fact once — never copy the rule into `development/` or the reason into `CONTRIBUTING.md` — and change both in the same commit when a rule changes.
- **`npm run maintain` is NOT part of the feature loop.** Its scans are advisory, never a gate; run them only on an explicit maintenance / update-deps branch. - **`npm run maintain` is NOT part of the feature loop.** Its scans are advisory, never a gate; run them only on an explicit maintenance / update-deps branch.
@@ -26,9 +27,10 @@ Don't silence the type system to force a green run. As an agent these are forbid
- `// @ts-nocheck`, `// @ts-ignore`, `// @ts-expect-error` - `// @ts-nocheck`, `// @ts-ignore`, `// @ts-expect-error`
- `// oxlint-disable` / `// oxlint-disable-next-line` - `// oxlint-disable` / `// oxlint-disable-next-line`
- editing `.oxlintrc.json` to silence a finding (e.g. turning `typescript/no-floating-promises` off)
- `as` casts used to push an expression through (type-aware oxlint already flags unsafe assertions) - `as` casts used to push an expression through (type-aware oxlint already flags unsafe assertions)
Fix the root cause with the type system instead — narrowing, generics, `satisfies`, conditional / mapped types, utility types (`NonNullable`, `Exclude`, …). TypeScript can express it; that's the intended tool. The `oxlint-disable`-location rule in [CONTRIBUTING.md § Rules the tools don't enforce](./CONTRIBUTING.md#rules-the-tools-dont-enforce) is a **human** last-resort convention (so a reviewer can spot a deliberate suppression) — it is not permission for you to add one. If the types genuinely cannot express something, stop and surface the conflict (commit message / handover) rather than suppress it. Fix the root cause with the type system instead — narrowing, generics, `satisfies`, conditional / mapped types, utility types (`NonNullable`, `Exclude`, …). TypeScript can express it; that's the intended tool. Suppressions — a source `oxlint-disable` **or** a `.oxlintrc.json` entry — are a **human** last resort, not a tool for you. If the types genuinely cannot express something, stop and surface the conflict (commit message / handover) rather than suppress it.
The same applies to the checks themselves: **never `git commit --no-verify`** (or otherwise skip a pre-commit / pre-push hook). The checks are fast and offline, so a redundant run is fine — bypassing a hook to get green is the identical anti-pattern. If a commit already skipped a hook, redo it through one: `git reset --soft HEAD~1 && git commit -C <skipped-sha>`. The same applies to the checks themselves: **never `git commit --no-verify`** (or otherwise skip a pre-commit / pre-push hook). The checks are fast and offline, so a redundant run is fine — bypassing a hook to get green is the identical anti-pattern. If a commit already skipped a hook, redo it through one: `git reset --soft HEAD~1 && git commit -C <skipped-sha>`.
@@ -42,7 +44,9 @@ Never start a long-lived / blocking process such as `npm run watch`. It runs unt
### Working on tasks ### Working on tasks
- **Task with subtasks** (a task that has indented children): create the branch with `npm run create:branch -- <prefix>/<desc>`, inferring the prefix from the task content (`feature/…` / `fix/…` / `chore/…`) — do not hand-write `git switch -c`, the command enforces the clean-tree / current-`main` / green-baseline precondition. Work on each subtask with commits, then present a concise handover for the user to review. Use this fixed shape: Every task — with or without subtasks — goes through the complete branching model:
- Create the branch with `npm run create:branch -- <prefix>/<desc>`, inferring the prefix from the task content (`feature/…` / `fix/…` / `chore/…`) — do not hand-write `git switch -c`, the command enforces the clean-tree / current-`main` / green-baseline precondition. Work with commits (each subtask gets one or more), then present a concise handover for the user to review. Use this fixed shape:
```md ```md
## Handover — <branch> ## Handover — <branch>
@@ -54,9 +58,7 @@ Never start a long-lived / blocking process such as `npm run watch`. It runs unt
Once the user has no further objections, merge back: `npm run create:finish` (on the branch — it merges `--no-ff`, runs `npm run verify`, and deletes the branch). The branching model is documented in [CONTRIBUTING.md § Branching model](./CONTRIBUTING.md#branching-model). Once the user has no further objections, merge back: `npm run create:finish` (on the branch — it merges `--no-ff`, runs `npm run verify`, and deletes the branch). The branching model is documented in [CONTRIBUTING.md § Branching model](./CONTRIBUTING.md#branching-model).
- **Leaf task** (no indented children): implement on the current branch and commit. Follow [CONTRIBUTING.md § Testing discipline (type-driven)](./CONTRIBUTING.md#testing-discipline-type-driven) throughout.
In both cases, follow [CONTRIBUTING.md § Testing discipline (type-driven)](./CONTRIBUTING.md#testing-discipline-type-driven). Each subtask gets one or more commits.
## Read these ## Read these
+78 -1
View File
@@ -7,6 +7,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
- restore the README's Synopsis and Examples sections: a quick-start recipe plus
real-world examples (primitive-union dispatch, a fallback, a property union
narrowed by the tagged-union matcher, and a widening variant)
- document the public API in the README: the four factories, when to use each,
what the `W` (widening) suffix means, and examples that bind the handler-map
function once
- add TSDoc to the four public matcher factories
## [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
- allow boolean, null and undefined in the matcher universe
## [0.4.0] - 2026-09-20
- move the `_` fallback out of handlers
## [0.3.0] - 2026-09-18
- condensed primitive matcher factories down to 2 from formerly 4
- house shared test helpers under `src/util/__tests__/`
## [0.2.0] - 2026-09-16
- allow ternaries and lowercase comments in oxlint
- switch `src/primitive.ts` prose from block comments to line comments
- remove the template's `match`/`P` example modules and their documentation, and point `src/index.ts` at the primitive matchers
## [0.1.8] - 2026-09-16
- upgrade dependencies
- ignore `@types/node` in `maintain:outdated` (misleading `latest` dist-tag)
- require extremely concise prose in `development/`
## [0.1.7] - 2026-09-16 ## [0.1.7] - 2026-09-16
- require a short summary under `[Unreleased]` in the changelog before a branch is finished - require a short summary under `[Unreleased]` in the changelog before a branch is finished
@@ -42,7 +109,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- basic setup - basic setup
[Unreleased]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.7...main [Unreleased]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.8.1...main
[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
[0.2.0]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.8...0.2.0
[0.1.8]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.7...0.1.8
[0.1.7]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.6...0.1.7 [0.1.7]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.6...0.1.7
[0.1.6]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.5...0.1.6 [0.1.6]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.5...0.1.6
[0.1.5]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.4...0.1.5 [0.1.5]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.4...0.1.5
+42 -7
View File
@@ -22,6 +22,8 @@ the rules so agents and humans don't diverge.
- **Test:** `npm run test`, `npm run test:ci` - **Test:** `npm run test`, `npm run test:ci`
- **Watch:** `npm run watch` - re-runs tests on file save, humans only - **Watch:** `npm run watch` - re-runs tests on file save, humans only
- **Checks:** `npm run check`, `npm run fix` - **Checks:** `npm run check`, `npm run fix`
- **Doc tests:** `npm run create:doc-tests` — compile the `ts`-tagged fences
in the prose docs into executed, gitignored tests
- **Verify:** `npm run verify` — the definition of done - **Verify:** `npm run verify` — the definition of done
- **Maintenance:** `npm run maintain` — advisory only - **Maintenance:** `npm run maintain` — advisory only
- **Individual fixes:** `npm run fix:oxfmt`, `npm run fix:oxlint` - **Individual fixes:** `npm run fix:oxfmt`, `npm run fix:oxlint`
@@ -41,7 +43,7 @@ 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 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 fix` | manual | Auto-resolve fixable issues (lint, format) | ~3s |
| `npm run maintain` | manual / CI (advisory) | `maintain:knip` + `maintain:outdated` (whole-project + network scans) | ~10s | | `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 maintain (auto, non-blocking) | on push to `main` | `npm run maintain` — reports, never fails the build | ~10s | | 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 | | 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 |
@@ -66,13 +68,38 @@ before the implementation. The loop is **type → red → green → refactor**:
4. **Refactor** — with the type system and the tests as the safety net, then 4. **Refactor** — with the type system and the tests as the safety net, then
`npm run verify` as the definition-of-done gate. `npm run verify` as the definition-of-done gate.
Every test pairs an `expectTypeOf(...)` with an `assert.*`; keep them together. Every test pairs an `expectTypeOf(...)` with an `assert.*`; keep them together
— including the expectations inside handler bodies, which pair with an
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-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)).
Each test body follows **AAA (Arrange–Act–Assert)** with labeled blocks
separated by a blank line: `// Arrange` sets up the inputs (e.g. the matcher
factory), `// Act` exercises the subject once from them (not a second
throwaway call), `// Assert` holds every check — type expectations first,
runtime assertions last; an empty block drops its label (see
[development/testing.md § AAA ordering](./development/testing.md#aaa-ordering)).
Type-first is enforced structurally: `npm test` runs `check:tsc` before the Type-first is enforced structurally: `npm test` runs `check:tsc` before the
test runner, so a wrong type can never be papered over by a passing assertion. test runner, so a wrong type can never be papered over by a passing assertion.
Per [AGENTS.md § Never do](./AGENTS.md#never-do), reach green honestly — fix the Per [AGENTS.md § Never do](./AGENTS.md#never-do), reach green honestly — fix the
types, never suppress the checks you can't make pass. Full rationale: types, never suppress the checks you can't make pass. Full rationale:
[development/testing.md](./development/testing.md). [development/testing.md](./development/testing.md).
## Documentation examples
Every `ts`-tagged fence in `README.md` / `CONTRIBUTING.md` is compiled into an
executed test, so a documented example cannot drift from the API. Describe each
fence with the paragraph directly above it (that text becomes the test title),
and keep its library import self-contained;
`npm run create:doc-tests` regenerates, formats and type-checks the tests under
`src/doc-test/__generated__/`. CI runs it in an explicit step before `test:ci`,
so run it yourself before `npm run verify` when you touched a fence. Why:
[development/docs.md](./development/docs.md).
## Code style and formatting ## Code style and formatting
`oxfmt` is the formatter and `oxlint` is the linter (with type-aware rules). `oxfmt` is the formatter and `oxlint` is the linter (with type-aware rules).
@@ -101,17 +128,21 @@ intended to run. A `<prefix>:<name>` script is implicitly aggregated by a
`<prefix>` script (if one exists) and run by the corresponding lefthook hook or `<prefix>` script (if one exists) and run by the corresponding lefthook hook or
CI step. Pick the prefix that matches the script's lifecycle: CI step. Pick the prefix that matches the script's lifecycle:
- `create:*` — front doors of the repo's own workflow; these mutate git state - `create:*` — front doors of the repo's own workflow; these produce or mutate
rather than the source. `create:branch` opens a unit of work, `create:finish` workflow artifacts (git state, generated doc-tests) rather than the
hand-written source. `create:branch` opens a unit of work, `create:finish`
closes the branch half, `create:release` closes the release half closes the branch half, `create:release` closes the release half
(maintainer-only). No bare `create` aggregator on purpose. (maintainer-only), and `create:doc-tests` regenerates the compiled prose
examples. No bare `create` aggregator on purpose.
- `check:*` — read-only verification; never modifies files. Aggregated by - `check:*` — read-only verification; never modifies files. Aggregated by
`npm run check`. `npm run check`.
- `fix:*` — mutating counterpart of a `check:*` script. Aggregated by - `fix:*` — mutating counterpart of a `check:*` script. Aggregated by
`npm run fix`; the diff is the review surface. `npm run fix`; the diff is the review surface.
- `test:*` — test scripts. `test` is the canonical entry point (`check:tsc` + - `test:*` — test scripts. `test` is the canonical entry point (`check:tsc` +
unit tests); `test:unit` skips the typecheck for fast local iteration; unit tests); `test:unit` skips the typecheck for fast local iteration;
`test:ci` 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/` (CI-only; `verify` stays coverage-free).
- `watch:*` — long-running watchers for the manual inner dev loop. Aggregated by - `watch:*` — long-running watchers for the manual inner dev loop. Aggregated by
`watch`. `watch`.
- `maintain:*` — advisory repo-maintenance scans: read-only, but whole-project - `maintain:*` — advisory repo-maintenance scans: read-only, but whole-project
@@ -144,7 +175,8 @@ reaches for by default:
[Script prefix convention](#script-prefix-convention). [Script prefix convention](#script-prefix-convention).
- **`oxlint-disable` directives live in source, not `.oxlintrc.json`.** The - **`oxlint-disable` directives live in source, not `.oxlintrc.json`.** The
trade-off must sit next to the code it silences. This is a _human_ last-resort trade-off must sit next to the code it silences. This is a _human_ last-resort
convention; agents must not add these — see convention; agents must not add one, nor edit `.oxlintrc.json` to silence a
finding (e.g. `typescript/no-floating-promises`) — see
[AGENTS.md § Never do](./AGENTS.md#never-do). (why: [AGENTS.md § Never do](./AGENTS.md#never-do). (why:
[development/tooling.md](./development/tooling.md#oxlint-disable-directives-live-next-to-the-code)) [development/tooling.md](./development/tooling.md#oxlint-disable-directives-live-next-to-the-code))
- **Don't put slow / network / whole-project scans in `check` or pre-commit.** - **Don't put slow / network / whole-project scans in `check` or pre-commit.**
@@ -173,6 +205,9 @@ reaches for by default:
alternatives and the known issues. When you change a rule, update its category alternatives and the known issues. When you change a rule, update its category
file in the same commit and cross-link the two. (why: file in the same commit and cross-link the two. (why:
[development/README.md](./development/README.md)) [development/README.md](./development/README.md))
- **Prose in `development/` is extremely concise.** When adding or changing a
decision, write fragments if needed — sacrifice grammar for concision. (why:
[development/README.md § Decision blocks](./development/README.md#decision-blocks))
## Branching model ## Branching model
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2025 tmu Copyright (c) 2026 tmu
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+231 -108
View File
@@ -1,35 +1,53 @@
# tiny-pattern-ts # tiny-pattern-ts
Pattern matching for TypeScript/ESM environments (F#-style, not regex). Exhaustive, type-safe pattern matching for TypeScript.
## Synopsis ## Synopsis
```ts ```ts
import { match, P } from "tiny-pattern-ts"; import { getTaggedUnionMatcher } from "tiny-pattern-ts";
const reply = (answer: "yes" | "no") => // 1. We have a union type
match(answer) type Contact =
.with(P.literal("yes"), (): "agreed" => "agreed") | { kind: "email"; address: string }
.with(P.literal("no"), (): "declined" => "declined") | { kind: "phone"; number: string }
.exhaustive(); | { kind: "messenger"; username: string };
reply("yes"); // "agreed" // 2. Create a matcher providing the discriminant property
const matchContact = getTaggedUnionMatcher<Contact>()("kind");
// 3. Define handlers for each branch of the union
const formatContact = matchContact({
email: (e) => `MAIL: ${e.address}`,
phone: (p) => `PHONE: ${p.number}`,
messenger: (m) => `MESSENGER: @${m.username}`,
});
// 4. Call the matcher with a value
const mailOutput = formatContact({ kind: "email", address: "ada@example.com" });
assert.equal(mailOutput, "MAIL: ada@example.com");
const phoneOutput = formatContact({ kind: "phone", number: "+1 555 0100" });
assert.equal(phoneOutput, "PHONE: +1 555 0100");
``` ```
## Description ## Description
`tiny-pattern-ts` gives TypeScript the shape of F#-style pattern matching: `tiny-pattern-ts` is a pattern-matching library for TypeScript.
a value flows through a chain of patterns, the first one that matches runs its
handler, and the handler receives the value narrowed to that pattern's type. The
"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 The main goal of `tiny-pattern-ts` is to make pattern matching type-safe with a
and no DSL to learn: `match(value)` returns a builder, `.with(pattern, handler)` lean syntax. This is accomplished by being exhaustive and passing typed
adds a case, and the chain ends in either `.exhaustive()` or `.otherwise(...)`. parameters per branch to the handlers — supported by an outstanding
The type-level contract is the feature — see autocomplete and a tiny footprint.
[development/library.md](./development/library.md) for the design decisions and
the known limitations. See [development/library.md](./development/library.md) for the design decisions
and [Caveats](#caveats) for the limits.
## Installation
```sh
npm install tiny-pattern-ts
```
## Requirements ## Requirements
@@ -41,129 +59,234 @@ the known limitations.
## Examples ## Examples
### Literal matching and `exhaustive()` A few real-world recipes. Each binds the handler-map function once and reuses
it, so the matcher is allocated a single time.
`.exhaustive()` returns the union of the handler return types and throws if no ### Dispatch on a primitive union
case matched. Annotate handler returns when you want literal types rather than
`string`: A result code is itself a finite union, so `getPrimitiveUnionMatcher` keys a
handler on each member:
```ts ```ts
type Answer = "yes" | "no"; import { getPrimitiveUnionMatcher } from "tiny-pattern-ts";
const reply = (answer: Answer): "agreed" | "declined" => type ResultCode = "ok" | "created" | "no-content";
match(answer)
.with(P.literal("yes"), (): "agreed" => "agreed")
.with(P.literal("no"), (): "declined" => "declined")
.exhaustive();
reply("yes"); // "agreed" 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);
``` ```
`exhaustive()` checks at runtime, not at compile time — TypeScript does not force ### Leave cases to a fallback
every union member to have a case (see
[development/library.md](./development/library.md#exhaustive-is-a-runtime-check)). Pass a fallback as the second argument to handle only part of the universe; it
Use `.otherwise(...)` when a fallback is wanted: receives the members the map leaves uncovered — here the parameter is
`"deprecated" | "gateway-timeout"`:
```ts ```ts
const label = (answer: Answer): string => import { getPrimitiveUnionMatcher } from "tiny-pattern-ts";
match(answer)
.with(P.literal("yes"), () => "agreed") type Status = "active" | "beta" | "deprecated" | "gateway-timeout";
.otherwise(() => "not agreed");
const rollout = getPrimitiveUnionMatcher<Status>()(
{ active: () => "enabled", beta: () => "enabled" },
(status) => `blocked (${status})`,
);
assert.equal(rollout("active"), "enabled");
assert.equal(rollout("deprecated"), "blocked (deprecated)");
``` ```
### Matching by `typeof` ### Dispatch on a property union
`P.type<T>(name)` pairs an explicit type `T` with the runtime `typeof` name it The value does not have to be the union itself. When a single property carries a
should test for: finite union, the tagged-union matcher keys on it and narrows the whole record to
the selected value:
```ts ```ts
const describe = (value: unknown): string => import { getTaggedUnionMatcher } from "tiny-pattern-ts";
match(value)
.with(P.type<string>("string"), (s) => `string of length ${s.length}`)
.with(P.type<number>("number"), (n) => `number ${n.toFixed(2)}`)
.otherwise(() => "something else");
```
The supported names are `string`, `number`, `boolean`, `bigint`, `symbol`, interface Invoice {
`undefined`, `object`, and `function`. `"object"` matches non-null objects and readonly currency: "eur" | "usd" | "jpy";
functions; `"undefined"` compares against `undefined` directly. readonly amount: number;
### Structural matching and discriminated unions
`P.shape(shape, refine?)` checks that every key in `shape` exists on the value.
A value that is itself a matcher is applied, otherwise it is compared with
strict equality. To narrow to a concrete type, pass a `refine` type guard:
```ts
interface Circle {
readonly kind: "circle";
readonly radius: number;
} }
interface Square { const matchCurrency = getTaggedUnionMatcher<Invoice>()("currency");
readonly kind: "square";
readonly side: number;
}
type Shape = Circle | Square; const symbolOf = matchCurrency({
eur: (i) => `€${i.amount.toFixed(2)}`,
usd: (i) => `$${i.amount.toFixed(2)}`,
jpy: (i) => `¥${i.amount.toFixed(0)}`,
});
const area = (shape: Shape): number => assert.equal(symbolOf({ currency: "usd", amount: 12.5 }), "$12.50");
match(shape) assert.equal(symbolOf({ currency: "jpy", amount: 900 }), "¥900");
.with(
P.shape({ kind: "circle" }, (v): v is Circle => "radius" in v),
(c) => Math.PI * c.radius ** 2,
)
.with(
P.shape({ kind: "square" }, (v): v is Square => "side" in v),
(s) => s.side ** 2,
)
.exhaustive();
``` ```
Without `refine`, `P.shape` returns a matcher for the shape's own type, not the ### Widen the return type
narrowed one. Nested matchers can be used in the shape object, for example
`P.shape({ name: P.type<string>("string") })`.
### Custom guards with `when` 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
`P.when` takes a type guard and infers the narrowed type from it: `string | string[] | undefined`:
```ts ```ts
const toNumber = (value: unknown): number => import { getPrimitiveUnionMatcherW } from "tiny-pattern-ts";
match(value)
.with(
P.when((v): v is string => typeof v === "string"),
(s) => Number.parseInt(s, 10),
)
.otherwise(() => 0);
```
### Widening with `any` type Field = "name" | "tags" | "note";
`P.any<T>(predicate)` takes a plain boolean predicate and a declared type `T`, const parse = getPrimitiveUnionMatcherW<Field>()({
for cases where the predicate cannot be written as a type guard: name: () => "Ada",
tags: () => ["admin", "beta"],
note: () => undefined,
});
```ts assert.equal(parse("name"), "Ada");
const firstNumber = (items: readonly unknown[]): number | undefined => assert.deepEqual(parse("tags"), ["admin", "beta"]);
match(items) assert.equal(parse("note"), undefined);
.with(
P.any<readonly number[]>(
(v) =>
Array.isArray(v) &&
v.every((item) => typeof item === "number"),
),
(xs) => xs[0],
)
.otherwise(() => undefined);
``` ```
## API ## API
Yet to be implemented The package exports four factories. Two axes pick one:
- **Universe** — a _primitive-union_ matcher matches a value that is itself a
finite union (`"yes" | "no"`); a _tagged-union_ matcher matches an object
discriminated by a property (`{ kind: … }`).
- **Return** — the _strict_ variant gives every handler one common return type
`R`; the _widening_ variant (`W`) widens the return value to the union of the
handler returns.
| Factory | Use when | Return |
| -------------------------------- | ------------------------------------------------------------------------- | --------------------- |
| `getPrimitiveUnionMatcher<T>()` | the value is the union and all handlers return the same type | one common `R` |
| `getPrimitiveUnionMatcherW<T>()` | the value is the union and handlers return different types | union of the handlers |
| `getTaggedUnionMatcher<T>()` | the value is a discriminated object and all handlers return the same type | one common `R` |
| `getTaggedUnionMatcherW<T>()` | the value is a discriminated object and handlers return different types | union of the handlers |
Bind the function that takes the handler map to a `match…` variable once and
reuse it; the [Examples](#examples) do this, so the builder is allocated once.
### Primitive-union matchers
`getPrimitiveUnionMatcher<T>()` takes the finite universe `T` and returns a
builder. Calling the builder with a handler map keyed by `T`'s members returns a
matcher: a function from `T` to the common return type.
```ts
import { getPrimitiveUnionMatcher } from "tiny-pattern-ts";
const matchAnswer = getPrimitiveUnionMatcher<"yes" | "no">();
const reply = matchAnswer({
yes: () => "agreed",
no: () => "declined",
});
const answer = reply("yes");
assert.equal(answer, "agreed");
```
Add a fallback as the second argument to leave members unhandled; the fallback
receives the remainder:
```ts
import { getPrimitiveUnionMatcher } from "tiny-pattern-ts";
const matchLabel = getPrimitiveUnionMatcher<"yes" | "no" | "maybe">();
const label = matchLabel(
{ yes: () => "agreed", no: () => "declined" },
(other) => `not sure: ${other}`, // other: "maybe"
);
const answer = label("yes");
assert.equal(answer, "agreed");
const fallback = label("maybe");
assert.equal(fallback, "not sure: maybe");
```
`getPrimitiveUnionMatcherW` is the same builder, but the matcher's return type
is the union of the handler return types rather than one common `R`.
### Tagged-union matchers
`getTaggedUnionMatcher<T>()` takes a discriminated union `T`. The returned
function takes the discriminant property's name and returns the handler-map
builder, keyed by that property's tags.
```ts
import { getTaggedUnionMatcher } from "tiny-pattern-ts";
type Shape =
{ kind: "circle"; radius: number } | { kind: "square"; side: number };
const matchShape = getTaggedUnionMatcher<Shape>()("kind");
const area = matchShape({
circle: (s) => Math.PI * s.radius ** 2,
square: (s) => s.side ** 2,
});
const circleArea = area({ kind: "circle", radius: 2 });
assert.equal(circleArea, Math.PI * 4);
const squareArea = area({ kind: "square", side: 3 });
assert.equal(squareArea, 9);
```
`getTaggedUnionMatcherW` is the widening counterpart, exactly as in the
primitive-union pair. The discriminant key is restricted to properties whose
values are tags; see [Caveats](#caveats) for the supported tags and the
`boolean` / `null` / `undefined` key projection.
### The universe
The primitive universe `T` must be a finite union of literals with no
value/stringification collision. A broad member (`string`, `number`, a template
literal) or a colliding pair (`true | "true"`, `1 | "1"`) is rejected at the
factory. The reasons and the rejected alternatives are in
[development/library.md](./development/library.md).
## Caveats
- **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).
## License ## License
MIT © 2025 tmu. See [LICENSE](./LICENSE). MIT © 2026 tmu. See [LICENSE](./LICENSE).
## Contributing ## Contributing
+22 -18
View File
@@ -5,31 +5,34 @@ Backlog and tracking for tiny-pattern-ts. Managed in vscode-todotasks format.
--- ---
Setup: Setup:
☐ Split off template into separate package => pi --session 01a07dde-7050-7054-bb36-1606d7eb2bc3 @high ☐ Split off template into separate package => pi --session 01a07dde-7050-7054-bb36-1606d7eb2bc3 @low
v1.0: v1.0:
☐ API surface is stable and fully typed ✔ API surface is stable and fully typed @done
☐ Finalize public exports in `src/index.ts` ✔ Finalize public exports in `src/index.ts` @done
☐ Document all exported types and functions ✔ Document all exported types and functions @done
☐ Add JSDoc for public APIs ✔ Add JSDoc for public APIs @done
☐ Test coverage meets threshold ✔ Test coverage meets threshold @done
☐ Achieve 100% branch coverage on `src/pattern.ts` ✔ Achieve 100% branch coverage on `src/primitive-union.ts` @done
☐ Achieve 100% branch coverage on `src/match.ts` ✔ Achieve 100% branch coverage on `src/index.ts` @done
☐ Achieve 100% branch coverage on `src/index.ts`
Bugs: Matcher:
✔ when using a union type as a property, the current behavior of tagged union matcher is @done
Enhancements: to pass never to handler parameters
→ new matcher function needed or can be fixed in tagged union matcher
✔ optional discriminant (`{ type?: "x" }`) is the same hole: the boolean/nullish change now admits the `undefined` tag, so the factory accepts the key, but `Extract<T, Record<K, V>>` still passes `never` to both the `x` and `undefined` handlers @done
Documentation: Documentation:
✔ Bring README.md back to its previous form — synopsis and examples restored, in the correct place @done
→ previous section order: title, tagline, Synopsis, Description, Requirements, Examples, API, License, Contributing
→ previous Examples order: literal/exhaustive, typeof, structural/discriminated unions, when, any
☐ Create `examples/` directory with runnable snippets ☐ Create `examples/` directory with runnable snippets
☐ Add comparison section vs. other TS pattern-matching libs in Readme.md ☐ Add comparison section vs. other TS pattern-matching libs in Readme.md
☐ Write migration guide for users coming from discriminated unions ☐ Why do we do this? => exhaustiveness encoded type safe
☐ Create backlog tasks for implementation ☐ Why this form? little syntax, data last, very small, autocomplete, strict typing in the handler; for more features use ts-pattern
☐ Validate code fences in Markdown (start with README.md) — compile the TypeScript examples against `src/` so the docs cannot drift from the API ✔ Write migration guide for users coming from discriminated unions @done (9/24/2026, 10:21:17 PM)
✔ Create backlog tasks for implementation @done (9/24/2026, 10:21:16 PM)
Workflow: ✔ Validate code fences in Markdown (start with README.md) — compile the TypeScript examples against `src/` so the docs cannot drift from the API @done
✔ 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: Maintenance:
☐ Serve CI coverage over a tiny self-hosted webserver (replace the zip artifact) @low ☐ Serve CI coverage over a tiny self-hosted webserver (replace the zip artifact) @low
@@ -43,3 +46,4 @@ Maintenance:
☐ Add a minimal dir-listing webserver to the gitea docker setup for serving landing page (reuse existing reverse proxy) ☐ 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>/`) ☐ 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 ☐ Browse to `…/tiny-pattern-ts/index.html` in the browser
☐ Add testing with TypeScript 5.0 baseline in CI
+4 -1
View File
@@ -42,7 +42,10 @@
"bestikk", "bestikk",
"silverwind", "silverwind",
"idris", "idris",
"todotasks" "todotasks",
"connor",
"injective",
"injectivity"
], ],
"ignorePaths": ["dist", "node_modules", "coverage", "*.svg", ".gitignore"] "ignorePaths": ["dist", "node_modules", "coverage", "*.svg", ".gitignore"]
} }
+4
View File
@@ -24,6 +24,7 @@ One file per category:
| [workflow.md](./workflow.md) | Branching and merging, script prefixes, feedback tiers, commit messages | | [workflow.md](./workflow.md) | Branching and merging, script prefixes, feedback tiers, commit messages |
| [tooling.md](./tooling.md) | Toolchain choices and configuration, editor setup | | [tooling.md](./tooling.md) | Toolchain choices and configuration, editor setup |
| [testing.md](./testing.md) | Test strategy and type-driven development | | [testing.md](./testing.md) | Test strategy and type-driven development |
| [docs.md](./docs.md) | Validating the Markdown code fences in the prose docs |
| [ci.md](./ci.md) | CI pipeline, runner image, coverage serving | | [ci.md](./ci.md) | CI pipeline, runner image, coverage serving |
| [publishing.md](./publishing.md) | Release and npm publishing | | [publishing.md](./publishing.md) | Release and npm publishing |
@@ -66,6 +67,9 @@ of downloading per job.
goes under a `## Known issues` section at the end of the file. goes under a `## Known issues` section at the end of the file.
- Replace a superseded decision in place rather than archiving it; git history - Replace a superseded decision in place rather than archiving it; git history
is the archive. is the archive.
- Terse is the point: humans skim and agents imitate the style already in the
file, so verbosity compounds edit over edit. Grammar loses to density here on
purpose.
## Adding to these docs ## Adding to these docs
+33
View File
@@ -97,6 +97,39 @@ Leave `act_runner`'s `force_pull` disabled.
(`docker rmi gitea.e1nsnull.de/tmu/act-ci:<version>`); do not reach for (`docker rmi gitea.e1nsnull.de/tmu/act-ci:<version>`); do not reach for
force-pull. 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.
## Coverage serving ## Coverage serving
#### Decision (2026-09) #### 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.
+306 -1
View File
@@ -3,4 +3,309 @@
The type-level design of the public API and the limitations it carries. The The type-level design of the public API and the limitations it carries. The
user-facing reference is [README § API](../README.md#api). user-facing reference is [README § API](../README.md#api).
Currently 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
#### Decision (2026-09)
A factory takes the universe and returns a builder; the builder takes a handler
map and an optional fallback:
```ts
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
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:
- `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:
1. `Handlers<T, R>` — the exhaustive form, and the contextual type of the
handler-map popup;
2. `Handled extends Exact<Partial<Handlers<T, R>>, Handled>` intersected with
`MustBePartial<T, Handled>`, plus `Fallback<T, Handled, R>` — a partial
handler map plus the fallback, rejected when the map already covers `T`.
#### Why
- **The fallback is an argument, not a property.** TypeScript fixes a property's
contextual type before it infers its sibling keys, so `_: (s) => …` in the
handler map can only see all of `T`, never `Exclude<T, keyof Handled>`. A later
argument is contextually typed from inference on an earlier one, so the split
is what makes the remainder expressible.
- **The redundant-fallback guard is an F-bounded constraint.** A map that
already covers `T` plus a fallback is rejected by folding
`MustBePartial<T, Handled>` into `Handled`'s own constraint. The guard is
checked _after_ `Handled` is inferred, so the contextual pass that types the
handler callbacks survives. The obvious conditional
`Exclude<T, keyof Handled> extends never ? …` in the fallback's parameter
type is evaluated while `Handled` is still its constraint and rejects every
partial map whose callbacks are context-sensitive.
- **Overload order keeps both messages.** #1 supplies the contextual type
(`a, b, c`); #2 accepts a partial map once a fallback is present, so its popup
is optional (`a?, b?, c?`). A gap without a fallback is reported against #1.
- **`R` needs an inference site.** `R` inside the `Exact<…>` constraint is not
one, so `handlers: Handled & Partial<Handlers<T, R>>` re-adds it; without that
`R` collapses to `unknown` when the handler params are inferred.
- **`Exact` restores the excess-property check.** TypeScript skips it for a
generic constraint, so without `Exact` the handler map accepts keys outside
`T`.
- Two factories, not four: the fallback is an argument, not a separate API.
#### Rejected
- **Single-object `_`** (the former shape). `_` sees only all of `T`; the
remainder is not expressible there, and an exhaustive map plus `_` was
accepted.
- **Curried handlers-first** — `(handlers)(fallback)`. Rejected: two calls for
the common case. It is not needed for the redundant-fallback guard, which the
F-bounded constraint already provides (see Why).
- **`this` / HKT self-reference.** `this` is post-construction (method bodies,
return positions); a parameter's contextual type is pre-construction.
`keyof this` in an interface method is the interface, not the literal.
- **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`).
- **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
near-miss member, no `_` in the exhaustive popup, `NoInfer`/floor, `keyof P`
counts optional keys, not pipe-friendly) hold where they still apply.
#### Known issue
- `PatternReturns` must be
`ReturnType<Extract<ValueOf<P>, (...args: never[]) => unknown>>` so it survives
the closed, partly-optional `P` constraints.
- `Parameters<typeof factory>[0]` resolves only the **last** overload, so it is
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)
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 `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
- Runtime dispatch indexes with the raw `shape`; `handlers[true]` coerces to
`"true"` at runtime exactly as `String` would. The `shape as string | number`
assertion only placates `TS2538` and buys the number fast path (an explicit
`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 rejected by the gate: user-facing, stated once in
[README § Caveats](../README.md#caveats).
+177 -8
View File
@@ -27,19 +27,188 @@ before the implementation.
- Runtime-first (classic red/green): it verifies the value, not the contract, - Runtime-first (classic red/green): it verifies the value, not the contract,
and the contract is the product. and the contract is the product.
- Testing the type only: it would not catch handler wiring, `exhaustive()` - Testing the type only: it would not catch handler dispatch or the `_`
throwing, or the `otherwise` fallback (see `src/index.test.ts`). fallback (see `src/primitive-union.test.ts`).
The runner is `node --test --strip-types "src/**/*.test.ts"` and the tiers are in The runner is `node --test --strip-types "src/**/*.test.ts"` and the tiers are
in
[CONTRIBUTING.md § Development commands](../CONTRIBUTING.md#development-commands). [CONTRIBUTING.md § Development commands](../CONTRIBUTING.md#development-commands).
`c8` uses V8 coverage, so the `--strip-types` source is instrumented without a `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 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
#### Decision (2026-09)
A handler's `expectTypeOf(shape)` is always paired with an assertion on the
argument `dispatch` actually passed: `assert.equal` where the handler runs for
one shape, `assert.ok(s === … || s === …)` over the set a `_` fallback accepts
(`assert` is imported as `strict`, so each comparison is `Object.is`). Where a
test should also prove that the _exact_ value reached the handler unchanged,
the fallback returns the shape verbatim and the call site asserts it.
#### Why
- The parameter's type is what the compiler inferred from the pattern; the
argument is what the runtime passed. Only the second can drift, and the keys
whose property name differs from their value (`true`, `null`, `1`) are
exactly where it can — see [library.md](./library.md).
- `String(shape)` at the call site keeps every type expectation and every
return-value assertion green; the argument assertions fail (10 of the 33
tests). Without them the suite never looks at the passed argument.
- Returning the shape verbatim costs a widening pattern nothing: the remainder
type joins the union of handler returns in place of a marker literal, so the
test still shows the widening it is named for.
#### Rejected
- A recorded `unknown[]` of every fallback call compared with `deepEqual`:
strong, but it couples the assertion to call order, and the sink sits three
blocks away from the value it observes.
- `typeof` checks: they cannot separate `2` from its key text `"2"`, which is
the drift a fallback with a numeric remainder can hit.
- One expected value asserted inline in a fallback: its argument is a _set_ of
shapes, so only the disjunction holds on every call.
## AAA ordering
The rule is in
[CONTRIBUTING.md § Testing discipline (type-driven)](../CONTRIBUTING.md#testing-discipline-type-driven).
#### Decision (2026-09)
Test bodies read arrange → act → assert: inputs (the factory) set up first, the
subject exercised once from them, all checks last — types then runtime. The
blocks are labeled with `// Arrange` / `// Act` / `// Assert` comments and
separated by a blank line; an empty block drops its label.
#### Why
- Interleaved setup/checks hide what runs vs. what is observed; the eye
re-reads the block to find the seams.
- A factory built mid-test invites a second throwaway call of the subject;
arranging it once makes the positive construction and the negative
`Parameters<…>` check share one source of truth.
- Labels make the seams explicit, not inferred — grep-able and reviewable
without reading the statements.
#### Rejected
- Unlabeled ordering (bare blank lines): the seams still have to be found by
reading; the labels cost nothing.
## Test helpers
The rule is enforced by `import/no-relative-parent-imports`; this section records
why the mechanism is shaped like this.
#### Decision (2026-09)
A shared test helper — code that scattered `*.test.ts` files import to do their
testing — lives under `src/util/__tests__/` and is addressed by the
`#test-utils/…` self-reference (`package.json#imports`:
`"#test-utils/*": "./src/util/__tests__/*"`), never by a relative path:
```ts
import { LspSession } from "#test-utils/lsp-completion.ts";
```
#### Why
- The lint rule bans upward (`../`) imports, and a cross-cutting helper can
always be placed above _some_ scattered consumer, wherever it goes. Name
beats path: a `#test-utils/…` specifier has no direction, so the rule never
fires and file moves only touch the one mapping in `package.json`.
- `#…` is Node's reserved prefix for _private_ subpath imports: publishing
`package.json` leaks nothing and resolves nothing for consumers.
- `__tests__` as the folder name is not about tests living there; it is the
directory pattern `tsconfig.build.json` already excludes, so a helper can
never be emitted into `dist/` and shipped by accident.
- Node's own resolver handles `#…` under `--strip-types`, and `tsc` resolves it
via the same `imports` field — one mechanism for runtime and type gate, no
loader needed.
- The helper uses `node:` builtins (it drives a language server), so
`import/no-nodejs-modules` is off for `src/util/__tests__/**` in
`.oxlintrc.json` — the same exception the `scripts/**` scope carries.
- Helpers carry no library coupling: their tests probe in-memory documents
whose contextual types are written inline, so only the helper's own contract
(marker handling, position, label extraction) is under test. A test that
asserts a _matcher's_ popup belongs with the matcher.
#### Rejected
- A helper beside the tests (`src/util/test.ts`, flat `src/`): composes only
while `src/` stays flat; the first nested test reaching it reintroduces the
banned upward import.
- Bare `~/…` specifier: not valid in `imports` (keys must start with `#`) —
resolution fails at runtime with `ERR_MODULE_NOT_FOUND`. A `#~/…` “home”
shorthand was dropped in review; `#test-utils/…` states what it is.
- tsconfig `paths` alias: resolves for `tsc` but not for plain
`node --test --strip-types` (no loader hook), breaking the fast tier.
- Turning `import/no-relative-parent-imports` off for test files: the rule
still guards non-test helpers importing each other, and the exemption is
only needed for the one specifier the mapping already solves cleanly.
## Autocomplete
#### Decision (2026-09)
Completion is verified by driving the repo's own language server
(`tsc --lsp --stdio`, the same server pi's LSP extension talks to) through
the test helper `#test-utils/lsp-completion.ts`
(`src/util/__tests__/lsp-completion.ts`), not through the type system:
```sh
node --strip-types src/util/__tests__/lsp-completion.ts <file> [<marker>]
```
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-union.test.ts`, where the same probe asserts the matcher's popup;
the CLI is for manual inspection.
#### Why
- Completion is a contextual-type property: it depends on which overload
signature TypeScript picks for the object literal, and no type-level assertion
observes that.
- `Parameters<typeof factory>[0]` resolves only the _last_ overload, so it is
not the popup's contextual type either — see
[library.md § Matcher shape](./library.md#matcher-shape).
- The server is the only ground truth; the script reproduces what the editor
shows.
#### Rejected
- **`expect-type` would not work**: there is no operator for “the popup offers
these labels”. `toExtend` / `toEqualTypeOf` test assignability and cannot say
which overload supplied the contextual type.
- **Checking by hand in the editor**: not reproducible in review or by an agent.
- **`@ts-expect-error` at a completion position**: it asserts the absence of a
compile error, not the presence of specific labels.
#### Known issue
- Each test spawns its own `tsc` server so the tests share no state and pass in
any order; the file is an integration test (~1.6 s) that needs `node_modules`.
`didOpen` is handled in order before the completion request, so no settle
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 ## Known issues
- The type-aware linter misidentifies `expectTypeOf()` as a floating promise, so - The type-aware linter misidentifies `expectTypeOf()` as a floating promise.
`src/index.test.ts` carries a file-level `oxlint-disable It is a known false positive, so `typescript/no-floating-promises` is off for
typescript/no-floating-promises` with an explanatory comment. It is a known `**/*.test.ts` in the `.oxlintrc.json` override rather than repeated as a
false positive, not a rule worth disabling project-wide (see file-level header (see
[tooling.md § oxlint-disable directives live next to the code](./tooling.md#oxlint-disable-directives-live-next-to-the-code)). [tooling.md § oxlint-disable directives live next to the code](./tooling.md#oxlint-disable-directives-live-next-to-the-code)).
+118 -9
View File
@@ -24,6 +24,8 @@ in [package.json](../package.json).
- **@spences10/pi-lsp** — read-only LSP code intelligence for AI agents - **@spences10/pi-lsp** — read-only LSP code intelligence for AI agents
(project-local `.pi/settings.json`); talks to this repo's TypeScript 7 via (project-local `.pi/settings.json`); talks to this repo's TypeScript 7 via
`tsc --lsp --stdio`. `tsc --lsp --stdio`.
- **vscode-languageserver-protocol** — LSP client and protocol types for the
autocomplete test helper (`src/util/__tests__/lsp-completion.ts`).
When each runs is in When each runs is in
[CONTRIBUTING.md § Feedback tiers](../CONTRIBUTING.md#feedback-tiers). [CONTRIBUTING.md § Feedback tiers](../CONTRIBUTING.md#feedback-tiers).
@@ -104,24 +106,55 @@ Type-aware oxlint is enabled via `options.typeAware: true` in `.oxlintrc.json`
#### Decision (2026-09) #### Decision (2026-09)
Known type-aware false positives are silenced with source-level `oxlint-disable` A type-aware rule that false-positives **at one site** is silenced with a
directives (see `src/pattern.ts`, `src/match.ts`, `src/index.test.ts`), not with source-level `oxlint-disable` directive (see `src/primitive-union.ts`). A rule that is
rules disabled in `.oxlintrc.json`. 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
`null` inputs) for `**/*.test.ts`. The same exemption is not repeated as a
file-level header in every affected file.
#### Why #### Why
- The disable sits next to the code it silences, visible to anyone reading the - A one-site disable sits next to the code it silences, visible to anyone
source. reading the source, and the rule stays on everywhere else.
- A file-class rule is a property of the file class, not of one line; the
override states it once, where the rest of the file-class config lives.
#### Rejected #### Rejected
- A project-wide disable in `.oxlintrc.json`: it hides the suppression from the - A project-wide disable in `.oxlintrc.json` for a one-site false positive: it
reader of the affected code. hides the exemption from the reader of the affected code and switches the rule
off repo-wide for a one-site problem.
- A repeated file-level `oxlint-disable` header for a file-class false positive:
the copies drift and scatter one config decision across the tree.
#### Known issue #### Known issue
- A source-level disable is a _human_ last resort. AI agents must not add one; - Both placements are _human_ last resorts. AI agents must neither add a source
they fix the type at its root (see [AGENTS.md § Never do](../AGENTS.md#never-do)). disable nor edit `.oxlintrc.json`; they fix the type at its root (see
[AGENTS.md § Never do](../AGENTS.md#never-do)).
### Unwanted stylistic rules are turned off in the config
#### Decision (2026-09)
A stylistic rule the project rejects is `"off"` in the `.oxlintrc.json` `rules`
map, not silenced at a use site. Current entries: `eslint/capitalized-comments`
(comments may start lowercase) and `eslint/no-ternary` (ternaries are allowed),
joining the oxfmt-superseded rules already off.
#### Why
- The rule is wrong for the whole project, not mis-firing at one site, so there
is no line to annotate.
- Keeping the two mechanisms separate keeps a source-level `oxlint-disable`
meaningful: it marks a lone exception.
#### Rejected
- A source-level `oxlint-disable` per use: the same exemption repeated at every
site, and oxfmt can move the site.
### `check:tsc` runs first ### `check:tsc` runs first
@@ -163,6 +196,50 @@ rules disabled in `.oxlintrc.json`.
are part of the public API. are part of the public API.
- The narrower scope keeps the signal high without config-file boilerplate. - The narrower scope keeps the signal high without config-file boilerplate.
### `knip` lists `src/index.ts` as an entry
#### Decision (2026-09)
`knip.json` declares `"entry": ["src/index.ts", "scripts/*.ts"]`.
#### Why
- Supplying `entry` **replaces** knip's default entry detection, which otherwise
derives the public entry from `package.json` `exports`. Adding `scripts/*.ts`
there therefore dropped the library entry, so knip resolved the package through
its `dist/index.js` output and reported the unreferenced source entry file
`src/index.ts` as an unused file.
- Naming the source entry restores the link between the public API and the
source graph without pointing knip at build output.
#### Rejected
- `paths` mapping `dist/index.*` back to `src/index.ts`: more config to model a
relation the explicit entry states directly, and it would break whenever the
build layout changes.
### `maintain:outdated` ignores `@types/node`
#### Decision (2026-09)
Pass `--ignore-packages @types/node`.
#### Why
- DT pins `@types/node`'s `latest` dist-tag to LTS (22.x); current-line types
ride other tags. Scan sees latest < installed — permanent "reverted", exit
1, zero signal. `--ignore-pre-releases` no help: 22.20.3 is stable.
#### Rejected
- `--types major,minor,patch`: hides real reverted reports elsewhere.
- `@types/node@26.*`: tag stays wrong across majors; un-pin per bump = ritual.
#### Known issue
- A genuinely behind `@types/node` goes unreported; match it to
`.node-version` by hand.
### `attw` targets ESM-only ### `attw` targets ESM-only
#### Decision (2026-09) #### Decision (2026-09)
@@ -202,6 +279,38 @@ project.
- The same script works by hand (whole project) and staged (scoped), so there is - The same script works by hand (whole project) and staged (scoped), so there is
no second command to maintain. no second command to maintain.
## Language server tooling
### `vscode-languageserver-protocol` backs the autocomplete helper
#### Decision (2026-09)
The autocomplete helper (`src/util/__tests__/lsp-completion.ts`) drives
`tsc --lsp --stdio` through `vscode-languageserver-protocol`'s
`createMessageConnection` and its typed request / notification objects, instead
of a hand-rolled JSON-RPC client.
#### Why
- Framing, `Content-Length` parsing, the pending-request map and server-request
dispatch are protocol plumbing the helper only reimplemented; the official
client owns them and tolerates the server's `string | number` ids.
- `InitializeRequest`, `CompletionRequest`, `DidOpenTextDocumentNotification`,
… carry their parameter and result types, so `CompletionList` / `CompletionItem`
replace the helper's ad-hoc shape guards.
- The `./node` entry re-exports `vscode-jsonrpc/node`, so one devDependency
supplies both the transport and the protocol types. It is test-only and never
ships (`files` publishes `dist/` only).
#### Rejected
- `vscode-languageclient`: the editor-side client with a full feature registry
— far more than a test helper needs.
- Generic JSON-RPC (`jsonrpc-lite`, `jayson`): still no LSP types, so they
replace framing only and leave the typed protocol surface unimplemented.
- Keeping the hand-rolled client: the low-level shape is the maintenance cost
the helper exists to remove, and it must be re-audited against the server.
## Editor and agent tooling ## Editor and agent tooling
### VSCode integration ### VSCode integration
+2 -1
View File
@@ -96,7 +96,8 @@ aggregator.
#### Why #### Why
- Both members create something real: a branch, a release. - Every member creates something real: a branch, a release, the compiled
doc-tests.
- It joined both lists in [CONTRIBUTING.md](../CONTRIBUTING.md) alongside its - It joined both lists in [CONTRIBUTING.md](../CONTRIBUTING.md) alongside its
first members, so it could not go invisible the way the retired `use:` prefix first members, so it could not go invisible the way the retired `use:` prefix
did. did.
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"$schema": "./node_modules/knip/schema.json", "$schema": "./node_modules/knip/schema.json",
"entry": ["scripts/*.ts"], "entry": ["src/index.ts", "scripts/*.ts"],
"ignoreDependencies": ["@runwisp/pubv"] "ignoreDependencies": ["@runwisp/pubv"]
} }
+1108 -432
View File
File diff suppressed because it is too large. Load diff
+21 -9
View File
@@ -1,7 +1,7 @@
{ {
"name": "tiny-pattern-ts", "name": "tiny-pattern-ts",
"version": "0.1.7", "version": "0.8.1",
"description": "Pattern matching for TypeScript/ESM environments (F#-style, not regex)", "description": "Exhaustive, type-safe pattern matching for TypeScript",
"keywords": [ "keywords": [
"adt", "adt",
"algebraic-data-types", "algebraic-data-types",
@@ -27,6 +27,10 @@
], ],
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
"imports": {
"#test-utils/*": "./src/util/__tests__/*",
"#test-tiny-pattern-ts": "./src/index.ts"
},
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -44,7 +48,8 @@
"check:oxfmt": "oxfmt --check ${LEFTHOOK_FILES:-.}", "check:oxfmt": "oxfmt --check ${LEFTHOOK_FILES:-.}",
"check:oxlint": "oxlint ${LEFTHOOK_FILES:-src scripts}", "check:oxlint": "oxlint ${LEFTHOOK_FILES:-src scripts}",
"check:tsc": "tsc", "check:tsc": "tsc",
"clean": "rm -rf dist coverage", "clean": "rm -rf dist coverage src/doc-test/__generated__/*.test.ts",
"create:doc-tests": "node --strip-types scripts/create-doc-tests.ts && sh -c 'oxfmt src/doc-test/__generated__/*.test.ts' && tsc -p src/doc-test/tsconfig.json",
"fix": "npm run fix:oxlint && npm run fix:oxfmt", "fix": "npm run fix:oxlint && npm run fix:oxfmt",
"fix:oxfmt": "oxfmt ${LEFTHOOK_FILES:-.}", "fix:oxfmt": "oxfmt ${LEFTHOOK_FILES:-.}",
"fix:oxlint": "oxlint --fix src scripts", "fix:oxlint": "oxlint --fix src scripts",
@@ -53,9 +58,11 @@
"create:release": "./scripts/release.sh", "create:release": "./scripts/release.sh",
"maintain": "npm run maintain:knip; npm run maintain:outdated", "maintain": "npm run maintain:knip; npm run maintain:outdated",
"maintain:knip": "knip --include dependencies,exports,files", "maintain:knip": "knip --include dependencies,exports,files",
"maintain:outdated": "check-outdated --ignore-pre-releases", "maintain:outdated": "check-outdated --ignore-pre-releases --ignore-packages @types/node",
"test": "npm run check:tsc && node --test --strip-types \"src/**/*.test.ts\"", "test": "npm run check:tsc && node --test --strip-types \"src/**/*.test.ts\"",
"test:ci": "c8 --reporter=text --reporter=lcov --reporter=html node --test --strip-types \"src/**/*.test.ts\"", "test:ci": "npm run test:coverage && npm run test:doc",
"test:coverage": "c8 --all --include \"src/**/*.ts\" --reporter=text --reporter=lcov --reporter=html --100 node --test --strip-types $(git ls-files 'src/*.test.ts')",
"test:doc": "node --test --strip-types \"src/doc-test/__generated__/*.test.ts\"",
"test:unit": "node --test --strip-types \"src/**/*.test.ts\"", "test:unit": "node --test --strip-types \"src/**/*.test.ts\"",
"verify": "npm run check && npm run test:unit", "verify": "npm run check && npm run test:unit",
"watch": "npm run watch:test", "watch": "npm run watch:test",
@@ -65,23 +72,28 @@
"setup": "npm run setup:git-commit-message", "setup": "npm run setup:git-commit-message",
"setup:git-commit-message": "git config commit.template commit-message-template" "setup:git-commit-message": "git config commit.template commit-message-template"
}, },
"dependencies": {
"type-fest": "^5.9.0"
},
"devDependencies": { "devDependencies": {
"@arethetypeswrong/cli": "^0.18.5", "@arethetypeswrong/cli": "^0.18.5",
"@runwisp/pubv": "^1.5.1", "@runwisp/pubv": "^1.5.1",
"@tsconfig/node26": "^26.0.1", "@tsconfig/node26": "^26.0.1",
"@tsconfig/strictest": "^2.0.8", "@tsconfig/strictest": "^2.0.8",
"@types/node": "^26.4.1", "@types/node": "^26.6.1",
"c8": "^12.0.0", "c8": "^12.0.0",
"check-outdated": "^3.0.0", "check-outdated": "^3.0.0",
"cspell": "^10.2.2", "cspell": "^10.3.2",
"expect-type": "1.4.0", "expect-type": "1.4.0",
"knip": "^6.34.0", "knip": "^6.34.0",
"lefthook": "^2.1.12", "lefthook": "^2.1.12",
"oxfmt": "^0.68.0", "mdast-util-from-markdown": "^2.0.3",
"oxfmt": "^0.70.0",
"oxlint": "^1.83.0", "oxlint": "^1.83.0",
"oxlint-tsgolint": "^7.0.2001", "oxlint-tsgolint": "^7.0.2001",
"publint": "^0.3.24", "publint": "^0.3.24",
"typescript": "^7.0.2" "typescript": "^7.0.2",
"vscode-languageserver-protocol": "^3.18.3"
}, },
"engines": { "engines": {
"node": ">=26" "node": ">=26"
+3 -3
View File
@@ -73,9 +73,9 @@ git show-ref --verify --quiet "refs/heads/${BASE}" || {
exit 1 exit 1
} }
# Derive the remote rather than hardcoding it: this repo has `origin` (ssh) and # Derive the remote rather than hardcoding it: `main` tracks `origin` (ssh)
# `origin_https`, and `main` tracks the latter — `git fetch origin main` would # here; a hardcoded name would check currency against a ref that may not
# check currency against a ref that is never updated here. # exist on a differently configured clone.
# `--quiet` echoes the unresolved `main@{upstream}` literal to stdout on # `--quiet` echoes the unresolved `main@{upstream}` literal to stdout on
# failure, so it cannot be paired with a `$(...) || fallback`; the non-quiet # failure, so it cannot be paired with a `$(...) || fallback`; the non-quiet
# form prints nothing on failure and the fallback runs. # form prints nothing on failure and the fallback runs.
+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": []
}
+20 -49
View File
@@ -1,56 +1,27 @@
/* oxlint-disable typescript/no-floating-promises -- expectTypeOf() is a sync type-assertion library that the type-aware linter misidentifies as a promise */
import { strict as assert } from "node:assert"; import { strict as assert } from "node:assert";
import { test } from "node:test"; import { test } from "node:test";
import { expectTypeOf } from "expect-type"; import { expectTypeOf } from "expect-type";
import { type Matcher, P, match } from "./index.ts"; import {
getPrimitiveUnionMatcher,
getPrimitiveUnionMatcherW,
getTaggedUnionMatcher,
getTaggedUnionMatcherW,
} from "./index.ts";
test("match returns a builder", () => { // The published entry point is the barrel (`package.json` exports
const builder = match("x"); // `./dist/index.js`), so every factory must be reachable from here. Importing it
expectTypeOf(builder).toHaveProperty("with"); // also loads the module, which is what lets c8's `--all` measure it — see
expectTypeOf(builder).toHaveProperty("exhaustive"); // development/ci.md § Coverage threshold.
expectTypeOf(builder).toHaveProperty("otherwise"); test("index: the public entry point re-exports every matcher factory", () => {
}); // Assert
expectTypeOf(getPrimitiveUnionMatcher).toBeFunction();
test("P.literal narrows to its literal type", () => { assert.equal(typeof getPrimitiveUnionMatcher, "function");
const matcher = P.literal("yes"); expectTypeOf(getPrimitiveUnionMatcherW).toBeFunction();
expectTypeOf(matcher).toMatchTypeOf<Matcher<"yes">>(); assert.equal(typeof getPrimitiveUnionMatcherW, "function");
assert.equal(matcher.matches("yes"), true); expectTypeOf(getTaggedUnionMatcher).toBeFunction();
assert.equal(matcher.matches("no"), false); assert.equal(typeof getTaggedUnionMatcher, "function");
}); expectTypeOf(getTaggedUnionMatcherW).toBeFunction();
assert.equal(typeof getTaggedUnionMatcherW, "function");
test("P.type narrows to the typeof target", () => {
const matcher = P.type<string>("string");
expectTypeOf(matcher).toMatchTypeOf<Matcher<string>>();
assert.equal(matcher.matches("hi"), true);
assert.equal(matcher.matches(42), false);
});
test("exhaustive() returns the union of handler return types", () => {
const result = match<"a" | "b">("a")
.with(P.literal("a"), () => 1 as const)
.with(P.literal("b"), () => "two" as const)
.exhaustive();
expectTypeOf(result).toEqualTypeOf<1 | "two">();
assert.equal(result, 1);
});
test("otherwise() falls back when no case matches", () => {
const result = match<"x" | "y" | "z">("z")
.with(P.literal("x"), (v): string => `got ${v}`)
.otherwise((v): string => `fallback ${v}`);
assert.equal(result, "fallback z");
});
test("exhaustive throws when no case matches", () => {
assert.throws(
() =>
match<"a" | "b" | "c">("c")
.with(P.literal("a"), () => "A")
.with(P.literal("b"), () => "B")
.exhaustive(),
/no matching case/,
);
}); });
+16 -2
View File
@@ -1,2 +1,16 @@
export { match, P } from "./match.ts"; /**
export type { Matcher, Pattern } from "./pattern.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";
-62
View File
@@ -1,62 +0,0 @@
import { P, type Matcher, type Pattern } from "./pattern.ts";
type Cases<R> = readonly (readonly [Matcher<unknown>, (value: unknown) => R])[];
interface MatchBuilder<T, R> {
with<U extends T, V>(
pattern: Matcher<U>,
handler: (value: U) => V,
): MatchBuilder<T, R | V>;
exhaustive(): R;
otherwise(handler: (value: T) => R): R;
}
const buildMatch = <T, R>(value: T, cases: Cases<R>): MatchBuilder<T, R> => {
const apply = (): R | undefined => {
for (const [matcher, handler] of cases) {
if (matcher.matches(value)) {
return handler(value);
}
}
return undefined;
};
const builder = {
with<U extends T, V>(
pattern: Matcher<U>,
handler: (value: U) => V,
): MatchBuilder<T, R | V> {
const nextCases: Cases<R | V> = [
...cases,
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
[pattern, handler as (value: unknown) => R | V],
];
return buildMatch(value, nextCases);
},
exhaustive(): R {
const result = apply();
if (result === undefined) {
throw new Error(
"tiny-pattern-ts: match.exhaustive() called with no matching case",
);
}
return result;
},
otherwise(handler: (value: T) => R): R {
for (const [matcher, run] of cases) {
if (matcher.matches(value)) {
return run(value);
}
}
return handler(value);
},
};
return builder;
};
export const match = <T>(value: T): MatchBuilder<T, never> =>
buildMatch<T, never>(value, []);
export type { Matcher, Pattern };
export { P };
+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
>;
-105
View File
@@ -1,105 +0,0 @@
/**
* Pattern matching primitives. Each constructor returns a lightweight
* matcher object whose `matches` method returns a type guard.
*/
export interface Matcher<T> {
readonly matches: (value: unknown) => value is T;
}
const literalMatcher = <
const L extends string | number | boolean | null | undefined,
>(
value: L,
): Matcher<L> => ({
matches: (candidate): candidate is L => candidate === value,
});
const typeMatcher = <T>(
type:
| "string"
| "number"
| "boolean"
| "bigint"
| "symbol"
| "undefined"
| "object"
| "function",
): Matcher<T> => {
const matches = (value: unknown): value is T => {
if (type === "undefined") {
return value === undefined;
}
if (type === "object") {
return (
(typeof value === "object" && value !== null) ||
typeof value === "function"
);
}
return typeof value === type;
};
return { matches };
},
whenMatcher = <T>(
predicate: (value: unknown) => value is T,
): Matcher<T> => ({
matches: predicate,
}),
whenMatcherAny = <T>(
predicate: (value: unknown) => boolean,
): Matcher<T> => ({
matches: (value: unknown): value is T => predicate(value),
}),
isNestedMatcher = (expected: unknown): expected is Matcher<unknown> =>
typeof expected === "object" &&
expected !== null &&
"matches" in expected,
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters
keysMatch = <S extends object>(shape: S, candidate: object): boolean => {
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
for (const key of Object.keys(shape) as (keyof S)[]) {
if (!(key in candidate)) {
return false;
}
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
const expected = shape[key],
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
actual = candidate[key as keyof object];
if (isNestedMatcher(expected)) {
if (!expected.matches(actual)) {
return false;
}
} else if (actual !== expected) {
return false;
}
}
return true;
},
structuralMatcher = <S extends object, T extends S>(
shape: S,
refine?: (value: S) => value is T,
): Matcher<T> => ({
matches: (value: unknown): value is T => {
if (typeof value !== "object" || value === null) {
return false;
}
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
const candidate = value as S;
if (!keysMatch(shape, candidate)) {
return false;
}
if (refine && !refine(candidate)) {
return false;
}
return true;
},
});
export const P = {
literal: literalMatcher,
type: typeMatcher,
when: whenMatcher,
any: whenMatcherAny,
shape: structuralMatcher,
} as const;
export type Pattern<T> = Matcher<T>;
File diff suppressed because it is too large. Load diff
+145
View File
@@ -0,0 +1,145 @@
import type { Exact } from "type-fest";
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<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, 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, Member<T, keyof Handled>>,
R
>;
// A fallback is redundant once the handler map covers `T`. The guard is folded
// into `Handled`'s own (self-referential) constraint so it is checked *after*
// inference; a conditional in the fallback's parameter type is evaluated while
// `Handled` is still its constraint and would reject context-sensitive partial
// maps. That placement also fixes where the diagnostic lands: the constraint
// 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.
type MustBePartial<T extends Matchable, Handled> =
PatternKey<T> 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: a handler map can otherwise
// carry keys outside `T`.
// 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 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>> & UniverseGate<T>,
fallback: Fallback<T, Handled, R> & UniverseGate<T>,
): 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 PrimitiveUnionMatcherWidening<T extends Matchable> {
<P extends Exact<Handlers<T, unknown>, P>>(
handlers: P & UniverseGate<T>,
): UnaryFn<T, PatternReturns<P>>;
<
R,
Handled extends Exact<Partial<Handlers<T, unknown>>, Handled> &
MustBePartial<T, Handled>,
>(
handlers: Handled & UniverseGate<T>,
fallback: Fallback<T, Handled, R> & UniverseGate<T>,
): UnaryFn<T, PatternReturns<Handled> | R>;
}
const dispatch =
(handlers: HandlerMap, fallback?: UnaryFn<never, unknown>) =>
(shape: Matchable): unknown =>
// `handlers[true]` already coerces to the `"true"` property at runtime,
// identical to `handlers[String(shape)]`, so indexing with `shape`
// directly is sound: `shape` is a facade-checked universe member and
// `PatternKey` only ever produces valid property keys. The assertion is
// needed solely because TypeScript forbids indexing with
// `boolean`/`null`/`undefined` (TS2538); it buys the number fast path.
(
handlers[
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
shape as string | number
] ??
fallback ??
(() => {
throw new Error(`Unhandled shape: ${String(shape)}`);
})
)(
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
shape as never,
);
/**
* Create a matcher for a finite primitive universe, with one common return
* type.
*
* Use it when the value itself is the union (`"yes" | "no"`) and every handler
* returns the same type.
*
* The returned builder takes a handler map keyed by the members; supplying a
* second fallback argument allows a partial map and receives the unhandled
* remainder.
*
* @typeParam T - The finite universe of primitive members to match.
* @returns A builder for the handler map, or the handler map plus a fallback.
* @example
* const matchAnswer = getPrimitiveUnionMatcher<"yes" | "no">();
* const describe = matchAnswer({
* yes: () => "agreed",
* no: () => "declined",
* });
* describe("yes"); // "agreed"
*/
export const getPrimitiveUnionMatcher = <
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;
+147
View File
@@ -0,0 +1,147 @@
import { strict as assert } from "node:assert";
import path from "node:path";
import { test } from "node:test";
import { expectTypeOf } from "expect-type";
import {
type CompletionResult,
type CompletionTarget,
LspSession,
} from "#test-utils/lsp-completion.ts";
const REPO_ROOT = path.resolve(import.meta.dirname, "../../..");
// The `#test-utils/*` self-reference (package.json#imports) is the one route
// scattered test files take to reach test helpers; this pins that it resolves
// and types without starting a language server
// (development/testing.md § Test helpers).
test("test-helper home: `#test-utils/…` resolves to the helper and types it", () => {
// Arrange
const target: CompletionTarget = {
file: "src/util/__tests__/lsp-completion.ts",
source: "",
};
// Assert — no session is constructed: the constructor spawns `tsc --lsp`,
// so only the resolved values and their types are checked here.
expectTypeOf(LspSession).toBeConstructibleWith("repo-root");
expectTypeOf<CompletionResult>().toMatchTypeOf<{
labels: readonly string[];
}>();
assert.equal(typeof LspSession, "function");
assert.equal(target.file, "src/util/__tests__/lsp-completion.ts");
});
// The helper drives a language server, so its contract is asserted against the
// server. Every document below is probed from memory and carries its
// contextual type inline, so the helper is tested without the library's code.
const probe = (source: string, marker?: string): Promise<CompletionResult> => {
const session = new LspSession(REPO_ROOT);
const target: CompletionTarget =
marker === undefined
? { file: "src/__probe.ts", source }
: { file: "src/__probe.ts", source, marker };
return session.completionLabelsAt(target).finally(() => session.close());
};
const HANDLERS = [
"type Handlers = { a: () => number; b: () => number; _?: () => number };",
"declare const apply: (handlers: Handlers) => Handlers;",
];
test("helper: a fresh object literal completes with its contextual keys", () => {
// Arrange
const source = [
...HANDLERS,
"const done = apply({",
" /*COMPLETE*/",
"});",
"export { done };",
].join("\n");
// Act
const probed = probe(source);
// Assert — the position is the marker's, which the helper strips
expectTypeOf(probed).toEqualTypeOf<Promise<CompletionResult>>();
return probed.then((result) => {
assert.deepEqual(result.position, { line: 3, character: 4 });
assert.deepEqual([...result.labels], ["_?", "a", "b"]);
});
});
test("helper: a handled key drops out of the popup", () => {
// Arrange
const source = [
...HANDLERS,
"const done = apply({",
" b: () => 1,",
" /*COMPLETE*/",
"});",
"export { done };",
].join("\n");
// Act
const probed = probe(source);
// Assert
expectTypeOf<CompletionResult["labels"]>().toEqualTypeOf<
readonly string[]
>();
return probed.then((result) => {
assert.deepEqual([...result.labels], ["_?", "a"]);
});
});
test("helper: a custom marker is located at the line start", () => {
// Arrange
const source = [
"type Handlers = { a: () => number; _?: () => number };",
"declare const apply: (handlers: Handlers) => Handlers;",
"const done = apply({",
"@@@",
"});",
"export { done };",
].join("\n");
// Act
const probed = probe(source, "@@@");
// Assert
expectTypeOf(probed).resolves.toEqualTypeOf<CompletionResult>();
return probed.then((result) => {
assert.deepEqual(result.position, { line: 3, character: 0 });
assert.deepEqual([...result.labels], ["_?", "a"]);
});
});
test("helper: labels are read from the server, not from a pattern literal", () => {
// Arrange
const source = [
'const text = "x";',
"const upper = text./*COMPLETE*/;",
"export { upper };",
].join("\n");
// Act
const probed = probe(source);
// Assert
expectTypeOf(probed).resolves.toHaveProperty("labels");
return probed.then((result) => {
assert.ok(result.labels.includes("toUpperCase"));
});
});
test("helper: a source without the marker rejects", () => {
// Arrange
const source = "const text = 1;\nexport { text };\n";
// Act
const probed = probe(source);
// Assert
expectTypeOf(probed).resolves.toEqualTypeOf<CompletionResult>();
return assert.rejects(probed, /marker not found/);
});
+254
View File
@@ -0,0 +1,254 @@
// oxlint-disable no-magic-numbers unicorn/no-null - tolerable here, this is a helper
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { pathToFileURL } from "node:url";
import {
type CompletionItem,
type CompletionList,
CompletionRequest,
ConfigurationRequest,
createMessageConnection,
DidOpenTextDocumentNotification,
InitializedNotification,
InitializeRequest,
type MessageConnection,
ShutdownRequest,
StreamMessageReader,
StreamMessageWriter,
} from "vscode-languageserver-protocol/node";
/**
* Print the completion labels TypeScript's language server offers at a marker
* inside a source file.
*
* Usage: node --strip-types src/util/__tests__/lsp-completion.ts <file> [<marker>]
*
* The marker (default `DEFAULT_MARKER`, a `COMPLETE` block comment) is stripped
* from the source before it is sent; the request position is where the marker
* stood. Point it at a scratch file whose expected type is a matcher pattern to
* inspect the popup.
*
* `LspSession` exposes the same probe to the test suite, so completion is
* asserted against the server rather than the type system.
*
* Why a script and not a type assertion: completion is a contextual-type
* property that `expect-type` cannot observe, and `Parameters<…>` resolves only
* a single overload. The server is the only ground truth. See
* development/testing.md § Autocomplete.
*/
const DEFAULT_MARKER = "/*COMPLETE*/";
const ARGV_PREFIX_LENGTH = 2;
const EXIT_DELAY_MS = 200;
const FAILURE_EXIT_CODE = 1;
const USAGE = "usage: lsp-completion.ts <file> [<marker>]";
export interface Position {
readonly line: number;
readonly character: number;
}
/** A document to probe: `source` carries `marker`, which is stripped. */
export interface CompletionTarget {
/** Path relative to the session's repo root; drives URI and resolution. */
readonly file: string;
readonly source: string;
readonly marker?: string;
}
export interface CompletionResult {
readonly position: Position;
readonly labels: readonly string[];
}
const completionLabels = (
result: CompletionItem[] | CompletionList | null,
): readonly string[] => {
if (result === null) {
return [];
}
const items = Array.isArray(result) ? result : result.items;
return items
.map((item) => item.label)
.toSorted((left, right) => left.localeCompare(right));
};
const markerPosition = (source: string, marker: string): Position => {
const index = source.indexOf(marker);
if (index === -1) {
throw new Error(`marker not found: ${marker}`);
}
const lines = source.slice(0, index).split("\n");
const lineIndex = lines.length - 1;
const line = lines[lineIndex];
return { line: lineIndex, character: line === undefined ? 0 : line.length };
};
/**
* One language server, initialized on first use, shared across probes. Callers
* own the lifecycle and must `close()` it.
*/
export class LspSession {
readonly #child: ChildProcessWithoutNullStreams;
readonly #connection: MessageConnection;
readonly #repoRoot: string;
#ready: Promise<void> | undefined;
public constructor(repoRoot: string) {
this.#repoRoot = repoRoot;
this.#child = spawn(
path.join(repoRoot, "node_modules", ".bin", "tsc"),
["--lsp", "--stdio"],
{ cwd: repoRoot },
);
this.#child.stderr.on("data", (chunk: Buffer) => {
process.stderr.write(chunk);
});
this.#connection = createMessageConnection(
new StreamMessageReader(this.#child.stdout),
new StreamMessageWriter(this.#child.stdin),
);
// Server -> client requests the server waits on: answer so it proceeds.
// `workspace/configuration` wants one reply per requested item; a
// catch-all covers the rest (e.g. `client/registerCapability`).
this.#connection.onRequest(ConfigurationRequest.type, ({ items }) =>
items.map(() => null),
);
this.#connection.onRequest(() => null);
this.#connection.listen();
}
public completionLabelsAt(
target: CompletionTarget,
): Promise<CompletionResult> {
return this.#ensureInitialized()
.then(() => this.#open(target))
.then(({ uri, position }) =>
this.#connection
.sendRequest(CompletionRequest.type, {
textDocument: { uri },
position,
context: { triggerKind: 1 },
})
.then((result) => ({
position,
labels: completionLabels(result),
})),
);
}
/** `shutdown` + close stdin, then kill the server; safe after a failed probe. */
public close(): Promise<void> {
return (
this.#connection
.sendRequest(ShutdownRequest.type)
// The TS 7 Go server logs a bare `context canceled` to stderr
// when it handles `exit`; EOF on stdin shuts it down cleanly
// (exit 0, no output) instead.
.then(() => {
this.#child.stdin.end();
})
.then(() => delay(EXIT_DELAY_MS))
.finally(() => {
this.#connection.dispose();
this.#child.kill();
})
);
}
#ensureInitialized(): Promise<void> {
this.#ready ??= this.#initialize();
return this.#ready;
}
#initialize(): Promise<void> {
return this.#connection
.sendRequest(InitializeRequest.type, {
processId: process.pid,
rootUri: pathToFileURL(this.#repoRoot).href,
workspaceFolders: [
{ uri: pathToFileURL(this.#repoRoot).href, name: "repo" },
],
capabilities: {
textDocument: {
completion: {
completionItem: { snippetSupport: false },
},
publishDiagnostics: {},
},
},
})
.then(() =>
this.#connection.sendNotification(
InitializedNotification.type,
{},
),
);
}
#open(target: CompletionTarget): Promise<{
readonly uri: string;
readonly position: Position;
}> {
const absolute = path.resolve(this.#repoRoot, target.file);
const marker = target.marker ?? DEFAULT_MARKER;
const position = markerPosition(target.source, marker);
const text = target.source.replace(marker, "");
const uri = pathToFileURL(absolute).href;
// The server handles `didOpen` in order before the completion request,
// so no settle delay is needed.
return this.#connection
.sendNotification(DidOpenTextDocumentNotification.type, {
textDocument: {
uri,
languageId: "typescript",
version: 1,
text,
},
})
.then(() => ({ uri, position }));
}
}
const main = (args: readonly string[]): Promise<void> => {
const [file, markerArgument] = args;
if (file === undefined) {
process.stderr.write(`${USAGE}\n`);
process.exitCode = FAILURE_EXIT_CODE;
return Promise.resolve();
}
const repoRoot = path.resolve(import.meta.dirname, "../../..");
const absolute = path.resolve(repoRoot, file);
const source = fs.readFileSync(absolute, "utf8");
const session = new LspSession(repoRoot);
return session
.completionLabelsAt({
file,
source,
marker: markerArgument ?? DEFAULT_MARKER,
})
.then(({ position, labels }) => {
process.stdout.write(
`\n[${file}] completions @ ${position.line}:${position.character}:\n${labels.join(", ")}\n`,
);
})
.finally(() => session.close());
};
const isEntryPoint = (): boolean => {
const [entry] = process.argv.slice(1, 2);
return entry !== undefined && import.meta.url === pathToFileURL(entry).href;
};
if (isEntryPoint()) {
main(process.argv.slice(ARGV_PREFIX_LENGTH)).catch((error: unknown) => {
process.stderr.write(
`${error instanceof Error ? error.message : String(error)}\n`,
);
process.exitCode = FAILURE_EXIT_CODE;
});
}
+2 -1
View File
@@ -8,5 +8,6 @@
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"verbatimModuleSyntax": true "verbatimModuleSyntax": true
}, },
"include": ["src", "scripts"] "include": ["src", "scripts"],
"exclude": ["src/doc-test"]
} }