35 Commits
Author SHA1 Message Date
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
21 changed files with 2415 additions and 640 deletions

No files matched your search

+3
View File
@@ -77,6 +77,9 @@ jobs:
- run: npm ci - run: npm ci
- run: npm run build - run: npm run build
- run: npm run check - run: npm run check
# 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
+36 -1
View File
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
- document the public API in the README: the four factories, when to use each,
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 ## [0.6.0] - 2026-09-22
- add `getTaggedUnionMatcher` / `getTaggedUnionMatcherW` for discriminated unions - add `getTaggedUnionMatcher` / `getTaggedUnionMatcherW` for discriminated unions
@@ -75,7 +106,11 @@ 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.6.0...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.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.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.4.0]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.3.0...0.4.0
+3 -2
View File
@@ -41,7 +41,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 |
@@ -125,7 +125,8 @@ CI step. Pick the prefix that matches the script's lifecycle:
`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:ci` runs the suite under c8 and fails below 100% coverage on `src/`
(CI-only; `verify` stays coverage-free).
- `watch:*` — long-running watchers for the manual inner dev loop. Aggregated by - `watch:*` — long-running watchers for the manual inner dev loop. Aggregated by
`watch`. `watch`.
- `maintain:*` — advisory repo-maintenance scans: read-only, but whole-project - `maintain:*` — advisory repo-maintenance scans: read-only, but whole-project
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2025 tmu Copyright (c) 2026 tmu
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+110 -6
View File
@@ -21,23 +21,127 @@ for the design decisions and [Caveats](#caveats) for the limits.
## 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 below 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",
});
reply("yes"); // "agreed"
```
Add a fallback as the second argument to leave members unhandled; the fallback
receives the remainder:
```ts
const matchLabel = getPrimitiveUnionMatcher<"yes" | "no" | "maybe">();
const label = matchLabel(
{ yes: () => "agreed", no: () => "declined" },
(other) => `not sure: ${other}`, // other: "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,
});
area({ kind: "circle", radius: 2 });
```
`getTaggedUnionMatcherW` is the widening counterpart, exactly as in the
primitive-union pair. The discriminant key is restricted to properties whose
values are tags; see [Caveats](#caveats) for the supported tags and the
`boolean` / `null` / `undefined` key projection.
### The universe
The primitive universe `T` must be a finite union of literals with no
value/stringification collision. A broad member (`string`, `number`, a template
literal) or a colliding pair (`true | "true"`, `1 | "1"`) is rejected at the
factory. The reasons and the rejected alternatives are in
[development/library.md](./development/library.md).
## Caveats ## Caveats
- **A value and its stringification collide.** Object keys stringify, so a - **Only finite universes are supported.** The factory must be given a finite
universe that mixes a member with the string it stringifies to — `1 | "1"`, union of literals; `string`, `number` and template literals are rejected. This
`true | "true"`, `null | "null"` — collapses to a single handler key and both is what lets the exhaustive overload be proven, so the runtime `dispatch`
members are routed to it. Use one form or the other. 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 - **`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 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. valid property key; neither satisfies the matcher's universe constraint.
- **`NaN` and `-0` cannot be matched specifically.** They have no literal type, - **`NaN` and `-0` cannot be matched specifically.** They have no literal type,
so both stay part of `number`. 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
+12 -67
View File
@@ -6,75 +6,21 @@ Backlog and tracking for tiny-pattern-ts. Managed in vscode-todotasks format.
Setup: Setup:
☐ Split off template into separate package => pi --session 01a07dde-7050-7054-bb36-1606d7eb2bc3 @low ☐ Split off template into separate package => pi --session 01a07dde-7050-7054-bb36-1606d7eb2bc3 @low
✔ straighten oxc rules @done
✔ oxc forbids ternary => remove oxc rule @done
✔ oxc wants comments to start comments with a capital letter => remove oxc rule @done
✔ there already exists a /* */ block comment in primitive.ts, => change to multi-line comment @done
✔ Remove example code files and its documentation and its exports from index.ts @high @done
✔ remove src/match.ts and its documentation @high @done
✔ remove src/pattern.ts and its documentation @high @done
✔ remove src/index.test.ts and its documentation @high @done
✔ exports from `src/index.ts` should only be the public API surface @done
v1.0: 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/primitive-union.ts` ✔ Achieve 100% branch coverage on `src/primitive-union.ts` @done
☐ Achieve 100% branch coverage on `src/index.ts` ✔ Achieve 100% branch coverage on `src/index.ts` @done
Testing:
✔ Cover autocomplete with real completion test cases in the suite @medium @done
→ `src/util/__tests__/lsp-completion.ts` (`#test-utils/…`) is the helper; the suite asserts its labels (see development/testing.md § Autocomplete)
→ wire it into `node --test` so a test asserts the offered labels
→ note: `Parameters<typeof factory>[0]` resolves only the *last* overload; use `@ts-expect-error` call sites for factory negatives, not `not.toExtend<Parameters<…>>`
✔ Drive the LSP completion helper through the LSP protocol library instead of a hand-rolled JSON-RPC client @medium @done
✔ Add `vscode-languageserver-protocol` and record the tooling decision @done
✔ Rewrite `src/util/__tests__/lsp-completion.ts` onto `createMessageConnection` and typed requests @done
✔ Update `development/testing.md § Autocomplete` for the new client @done
✔ Run `npm run verify` and check the task off @done
☐ Test a literal union widened with `(string & {})` — `"red" | "green" | "yellow" | (string & {})` @medium
→ autocomplete should still offer the literals; arbitrary strings stay assignable and fall through to the fallback
☐ Type hole when using broad types like string as a universe @high
☐ handlers cannot be exhaustive, yet it is possible to not have a fallback
☐ one test even exploits this, I think for a negative test
☐ does the same type hole exist for tagged-union matchers or primitive-union matchers only?
Matcher: Matcher:
✔ Clean up: adopt the 3-overload matcher (`src/prototype-ac2.ts`) and delete the prototypes @high @done ✔ when using a union type as a property, the current behavior of tagged union matcher is @done
→ `getMatcher` / `getMatcherW`, each with overloads `ExhaustiveLoose` → `Fallback` → `Handlers` (order is load-bearing)
→ fold into `src/primitive.ts` / the public API; drop `src/prototype*.ts`
✔ `_` should receive only the unhandled `T` keys, not all of `T` @medium @done
→ the fallback is now a second argument: `(handlers, (s) => …)`, `s: Exclude<T, keyof handlers>`
✔ A fallback for an already-exhaustive handler map must be a compile error @medium @done
→ rejected by an F-bounded constraint on `Handled` (checked *after* inference); a conditional in the fallback parameter is evaluated too early and breaks contextual typing
✔ Implement matcher with similar API like matcher from primitive.ts @high @done
→ `getTaggedUnionMatcher` / `getTaggedUnionMatcherW`, curried on the discriminant key
→ fallback is the second argument; `_` removed
✔ Rename `primitive` to `primitive-union` and `getMatcher` to `getPrimitiveMatcher` @medium @done
→ `src/primitive.ts` / `src/primitive.test.ts` → `primitive-union.*`
→ `getMatcherW` → `getPrimitiveMatcherW` for symmetry with the tagged-union pair
→ update `src/index.ts`, `development/library.md` and any README references
→ shipped as `getPrimitiveUnionMatcher` / `getPrimitiveUnionMatcherW`; kept `Union` for symmetry with the tagged-union pair
☐ when using a union type as a property, the current behavior of tagged union matcher is
to pass never to handler parameters to pass never to handler parameters
→ new matcher function needed or can be fixed in tagged union matcher → 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 ✔ 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
Bugs:
✔ TS 7 LSP server logs `context canceled` on stderr at shutdown @done
→ `handleExit` returns `io.EOF`, cancelling the background context while `Session.updateWatches` is still in flight; the bare error is flushed to stderr and the server exits 1
→ close stdin after `shutdown` instead of sending `exit`; the server exits cleanly (code 0, no output), kill kept as a fallback
Enhancements:
✔ Allow boolean literals in primitive union patterns (e.g. `true: () => "yes"`) @medium @done
✔ Are there other primitive types that should be supported in union patterns? (e.g. `bigint`, `symbol`) @medium @done
→ added boolean, null and undefined; rejected `symbol` (compile-time brand, nothing at runtime) and `bigint` (not a property key)
✔ Allow boolean, null and undefined discriminant values in tagged-union patterns @medium @done
→ moved the `PatternKey` / `PatternParam` projection to `matcher-shared.ts` and keyed the tagged-union handler map through it; see development/library.md § Tagged-union matcher
Documentation: Documentation:
☐ Bring README.md back to its previous form — synopsis and examples restored, in the correct place ☐ Bring README.md back to its previous form — synopsis and examples restored, in the correct place
@@ -82,14 +28,12 @@ Documentation:
→ previous Examples order: literal/exhaustive, typeof, structural/discriminated unions, when, any → previous Examples order: literal/exhaustive, typeof, structural/discriminated unions, when, any
☐ Create `examples/` directory with runnable snippets ☐ Create `examples/` directory with runnable snippets
☐ Add comparison section vs. other TS pattern-matching libs in Readme.md ☐ Add comparison section vs. other TS pattern-matching libs in Readme.md
☐ Why do we do this? => exhaustiveness encoded type safe
☐ Why this form? little syntax, data last, very small, autocomplete, strict typing in the handler; for more features use ts-pattern
☐ Write migration guide for users coming from discriminated unions ☐ Write migration guide for users coming from discriminated unions
☐ Create backlog tasks for implementation ☐ Create backlog tasks for implementation
✔ Document the matcher design paths in `development/` — union vs overload merge, inferred universe (`NoInfer`), conditional `RequireKeys`, cases-first — and why each was abandoned @done
☐ Validate code fences in Markdown (start with README.md) — compile the TypeScript examples against `src/` so the docs cannot drift from the API ☐ Validate code fences in Markdown (start with README.md) — compile the TypeScript examples against `src/` so the docs cannot drift from the API
Workflow:
✔ Resolve the finish/push tension: `create:finish` leaves `main` ahead of its upstream while `create:branch` refuses until `main` matches upstream — decide whether `finish` should push or `branch` should compare only `BEHIND` (see development/workflow.md) @done
Maintenance: 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
☐ Explore serving coverage for non-tag pushes (e.g. `main/coverage`, PR previews) @low ☐ Explore serving coverage for non-tag pushes (e.g. `main/coverage`, PR previews) @low
@@ -102,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
+3 -1
View File
@@ -43,7 +43,9 @@
"silverwind", "silverwind",
"idris", "idris",
"todotasks", "todotasks",
"connor" "connor",
"injective",
"injectivity"
], ],
"ignorePaths": ["dist", "node_modules", "coverage", "*.svg", ".gitignore"] "ignorePaths": ["dist", "node_modules", "coverage", "*.svg", ".gitignore"]
} }
+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)
+139 -26
View File
@@ -5,8 +5,39 @@ user-facing reference is [README § API](../README.md#api).
The matchers are implemented in `src/primitive-union.ts` (`getPrimitiveUnionMatcher` / The matchers are implemented in `src/primitive-union.ts` (`getPrimitiveUnionMatcher` /
`getPrimitiveUnionMatcherW`) and `src/tagged-union.ts` (`getTaggedUnionMatcher` / `getPrimitiveUnionMatcherW`) and `src/tagged-union.ts` (`getTaggedUnionMatcher` /
`getTaggedUnionMatcherW`), re-exported from `src/index.ts`; the rest of the `getTaggedUnionMatcherW`), re-exported from `src/index.ts`; the rest of `src/`
library is placeholder code. is implementation detail.
## Public surface
#### Decision (2026-09)
`src/index.ts` exports the four factories and nothing else. Every exported
function carries TSDoc; the builder types and the `matcher-shared.ts`
vocabulary stay internal.
#### Why
- The factories are the whole contract: a consumer calls one and never needs to
name the builder type it returns.
- The builder interfaces are already the inferred return types, so they travel
into the emitted `.d.ts` regardless. Exporting them would only make them
nameable while freezing the internal `Strict` / `Widening` overload split as
API.
- TSDoc travels into the emitted declarations, so editor hovers and the
published package document the API without a hand-written `.d.ts`.
#### Rejected
- **Exporting the builder types** (`PrimitiveUnionMatcher`, …). Nameable, but it
grows the surface for no call-site benefit and pins the `Strict` / `Widening`
split.
- **Exporting the `matcher-shared.ts` vocabulary** (`Matchable`, `UnaryFn`,
`PatternKey`, `Member`, `PatternReturns`, …). They appear in the public
signatures, but a consumer never needs to name them; exporting them would
freeze plumbing as API.
- **A hand-written `.d.ts` or a separate API document.** It would drift from the
implementation; TSDoc is generated from the source.
## Matcher shape ## Matcher shape
@@ -97,9 +128,11 @@ Each factory is two overloads whose order is load-bearing:
#### Decision (2026-09) #### Decision (2026-09)
`src/matcher-shared.ts` holds the seven universe-agnostic pieces both matchers `src/matcher-shared.ts` holds the universe-agnostic pieces both matchers use:
use: `UnaryFn`, `PatternReturns`, `RedundantFallback`, `HandlerMap`, the shared `UnaryFn`, `PatternReturns`, `RedundantFallback`, `HandlerMap`, the shared
`Matchable` universe, and the `PatternKey` / `PatternParam` key projection. `Matchable` universe, the `PatternKey` key projection and its `Member` inverse,
and the `Stringified` / `Collisions` / `UnsupportedReason` / `UnsupportedUniverse`
/ `UniverseGate` universe gate.
#### Why #### Why
@@ -118,8 +151,71 @@ use: `UnaryFn`, `PatternReturns`, `RedundantFallback`, `HandlerMap`, the shared
`Fallback` and `MustBePartial`.** Each is built from its own universe `Fallback` and `MustBePartial`.** Each is built from its own universe
(`Tags`/`MapTaggedUnion` vs the primitive values); abstracting over the (`Tags`/`MapTaggedUnion` vs the primitive values); abstracting over the
F-bounded `Handled` constraint that makes the remainder work risks the F-bounded `Handled` constraint that makes the remainder work risks the
contextual typing it exists to preserve. `Matchable` and the `PatternKey` / contextual typing it exists to preserve. `Matchable`, the `PatternKey` /
`PatternParam` projection are the pieces both universes genuinely share. `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 ## Tagged-union matcher
@@ -149,28 +245,44 @@ The key is a separate call because `K` is inferred from its literal argument and
#### Why #### Why
- **Same fallback/remainder machinery as the primitive-union matcher.** `HandledMembers` - **Same fallback/remainder machinery as the primitive-union matcher.** `HandledTags`
maps the handled tags to their members and `Exclude<T, …>` is the fallback's recovers the tag values the map handled (`Member<Tags, keyof Handled>`) and
parameter; the redundant-fallback guard is the same F-bounded constraint. Only `Narrowed<T, K, Exclude<Tags, …>>` is the fallback's parameter; the
the "universe" changes — `T`'s members instead of primitive values. 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 - **`T extends object`, not `Record<PropertyKey, unknown>`.** An `interface` has
no implicit index signature, so the `Record` constraint would reject no implicit index signature, so the `Record` constraint would reject
interface-based unions. The runtime reads the tag off `object` with one 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`. assertion, the tagged twin of the primitive-union dispatch's `shape as string | number`.
- **`MapTaggedUnion` distributes with `Extract`.** A duplicated tag yields a - **`Narrowed` distributes over `T`, narrowing `K` to the tag.** A member whose
union of members instead of dropping one. `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 - **A `boolean` / `null` / `undefined` tag goes through the shared
`PatternKey` / `PatternParam` projection.** `Discriminated` admits those tags `PatternKey` / `Member` projection.** `Discriminated` admits those tags
(they are in `Tag`), but they cannot key a mapped type, so the handler map is (they are in `Tag`), but they cannot key a mapped type, so the handler map is
keyed by the stringified form (`true` → `"true"`) and `PatternParam` inverts keyed by the stringified form (`true` → `"true"`) and `Member` inverts it
it to recover the member. This is the same projection the primitive-union against the tag set to recover the member. This is the same projection the
matcher uses over its universe, which is why it lives in `matcher-shared.ts`. primitive-union matcher uses over its universe, which is why it lives in
`matcher-shared.ts`.
#### Known issue #### Known issue
- A member's tag must be unique across the union; two members with the same tag - A tag need not be unique across the union. Two members sharing one is not a
collapse to a union under one handler. The same holds for a tag colliding with soundness hole: they select a single runtime key, so one handler receiving
its stringification (`true | "true"`) — see [README § Caveats](../README.md#caveats). 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 ## Primitive universe
@@ -180,11 +292,12 @@ The universe (`Matchable`) is `string | number | boolean | null | undefined`,
with `boolean` admitted as `true | false`. with `boolean` admitted as `true | false`.
`boolean`/`null`/`undefined` are not property keys, so handler-map keys are a `boolean`/`null`/`undefined` are not property keys, so handler-map keys are a
projection (`PatternKey`: each member stringified) and `PatternParam` inverts projection (`PatternKey`: each member stringified) and `Member` inverts it
it, so callbacks receive the real member (`true`, not `"true"`). The popup against the universe, so callbacks receive the real member (`true`, not
offers `true`, `false`, `null`, `undefined` by name (verified over LSP). The `"true"`; the standalone string `"true"` stays `"true"`). The popup offers
same projection is shared with the tagged-union matcher; see `true`, `false`, `null`, `undefined` by name (verified over LSP). The same
§ Tagged-union matcher. projection is shared with the tagged-union matcher; see § Tagged-union matcher.
The supported universes are constrained as described in § Supported universes.
#### Why #### Why
@@ -194,5 +307,5 @@ same projection is shared with the tagged-union matcher; see
`String()` defeats V8's numeric-key path: measured ~2× on number-keyed `String()` defeats V8's numeric-key path: measured ~2× on number-keyed
dispatch). dispatch).
- `symbol`/`bigint`/`NaN`/`-0` are rejected, and a member colliding with its - `symbol`/`bigint`/`NaN`/`-0` are rejected, and a member colliding with its
stringification is unguarded: user-facing, stated once in stringification is rejected by the gate: user-facing, stated once in
[README § Caveats](../README.md#caveats). [README § Caveats](../README.md#caveats).
+7 -1
View File
@@ -35,7 +35,8 @@ 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 ## Handler arguments
@@ -198,6 +199,11 @@ the CLI is for manual inspection.
delay is needed. delay is needed.
- The server answers some requests with a string id (`client/registerCapability`); - The server answers some requests with a string id (`client/registerCapability`);
the client must tolerate `string | number` ids or the server stalls. 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
+22
View File
@@ -196,6 +196,28 @@ joining the oxfmt-superseded rules already off.
are part of the public API. are part of the public API.
- The narrower scope keeps the signal high without config-file boilerplate. - The narrower scope keeps the signal high without config-file boilerplate.
### `knip` lists `src/index.ts` as an entry
#### 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` ### `maintain:outdated` ignores `@types/node`
#### Decision (2026-09) #### Decision (2026-09)
+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"]
} }
+424 -424
View File
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "tiny-pattern-ts", "name": "tiny-pattern-ts",
"version": "0.6.0", "version": "0.8.1",
"description": "Pattern matching for TypeScript/ESM environments (F#-style, not regex)", "description": "Pattern matching for TypeScript/ESM environments (F#-style, not regex)",
"keywords": [ "keywords": [
"adt", "adt",
@@ -58,7 +58,7 @@
"maintain:knip": "knip --include dependencies,exports,files", "maintain:knip": "knip --include dependencies,exports,files",
"maintain:outdated": "check-outdated --ignore-pre-releases --ignore-packages @types/node", "maintain:outdated": "check-outdated --ignore-pre-releases --ignore-packages @types/node",
"test": "npm run check:tsc && node --test --strip-types \"src/**/*.test.ts\"", "test": "npm run check:tsc && node --test --strip-types \"src/**/*.test.ts\"",
"test:ci": "c8 --reporter=text --reporter=lcov --reporter=html node --test --strip-types \"src/**/*.test.ts\"", "test:ci": "c8 --all --include \"src/**/*.ts\" --reporter=text --reporter=lcov --reporter=html --100 node --test --strip-types \"src/**/*.test.ts\"",
"test:unit": "node --test --strip-types \"src/**/*.test.ts\"", "test:unit": "node --test --strip-types \"src/**/*.test.ts\"",
"verify": "npm run check && npm run test:unit", "verify": "npm run check && npm run test:unit",
"watch": "npm run watch:test", "watch": "npm run watch:test",
@@ -83,7 +83,7 @@
"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", "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",
+27
View File
@@ -0,0 +1,27 @@
import { strict as assert } from "node:assert";
import { test } from "node:test";
import { expectTypeOf } from "expect-type";
import {
getPrimitiveUnionMatcher,
getPrimitiveUnionMatcherW,
getTaggedUnionMatcher,
getTaggedUnionMatcherW,
} from "./index.ts";
// The published entry point is the barrel (`package.json` exports
// `./dist/index.js`), so every factory must be reachable from here. Importing it
// also loads the module, which is what lets c8's `--all` measure it — see
// development/ci.md § Coverage threshold.
test("index: the public entry point re-exports every matcher factory", () => {
// Assert
expectTypeOf(getPrimitiveUnionMatcher).toBeFunction();
assert.equal(typeof getPrimitiveUnionMatcher, "function");
expectTypeOf(getPrimitiveUnionMatcherW).toBeFunction();
assert.equal(typeof getPrimitiveUnionMatcherW, "function");
expectTypeOf(getTaggedUnionMatcher).toBeFunction();
assert.equal(typeof getTaggedUnionMatcher, "function");
expectTypeOf(getTaggedUnionMatcherW).toBeFunction();
assert.equal(typeof getTaggedUnionMatcherW, "function");
});
+8
View File
@@ -1,3 +1,11 @@
/**
* The public entry point of `tiny-pattern-ts`.
*
* Exports the four matcher factories and nothing else; the builder types they
* return and the rest of `src/` are implementation detail.
*
* @module
*/
export { export {
getPrimitiveUnionMatcher, getPrimitiveUnionMatcher,
getPrimitiveUnionMatcherW, getPrimitiveUnionMatcherW,
+57 -15
View File
@@ -1,4 +1,5 @@
import type { ValueOf } from "type-fest"; /* 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 // The primitive-union and tagged-union matchers differ in their universe, but the
// handler/fallback plumbing is identical; these are the shared pieces. The // handler/fallback plumbing is identical; these are the shared pieces. The
@@ -16,11 +17,11 @@ export type Matchable = string | number | boolean | null | undefined;
// `boolean`, `null` and `undefined` cannot be property keys, so a mapped type // `boolean`, `null` and `undefined` cannot be property keys, so a mapped type
// over a universe that includes one keys each such member by its // over a universe that includes one keys each such member by its
// stringification. `PatternParam` inverts that projection, so a handler callback // stringification. `Member` inverts that projection against the universe, so a
// still receives the *real* member (`true`, not `"true"`). Both matchers use the // handler callback still receives the *real* member (`true`, not `"true"`).
// projection: the primitive-union matcher over its universe, the tagged-union // Both matchers use the projection: the primitive-union matcher over its
// matcher over a discriminant property's values. See README § Caveats for the // universe, the tagged-union matcher over a discriminant property's values. See
// limits. // README § Caveats for the limits.
export type PatternKey<T> = T extends boolean export type PatternKey<T> = T extends boolean
? T extends true ? T extends true
? "true" ? "true"
@@ -30,15 +31,56 @@ export type PatternKey<T> = T extends boolean
: T extends undefined : T extends undefined
? "undefined" ? "undefined"
: T; : T;
export type PatternParam<K> = K extends "true"
? true // The member(s) of `T` whose `PatternKey` is `K`: the universe-keyed inverse of
: K extends "false" // `PatternKey`. The key alone cannot recover the member (`"true"` and `true`
? false // share it), so the handler parameter is derived from `T` instead. For a
: K extends "null" // supported (injective) universe the result is a single member.
? null export type Member<
: K extends "undefined" T extends Matchable,
? undefined K extends PropertyKey,
: K; > = 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 // `Extract` drops optional handlers (`undefined`) so `PatternReturns` also works
// when `P`'s constraint has optional keys. // when `P`'s constraint has optional keys.
+393 -32
View File
@@ -495,26 +495,394 @@ test("getPrimitiveUnionMatcher factory rejects non-exhaustive boolean and nullis
); );
}); });
test("getPrimitiveUnionMatcher: an open universe keeps the fallback's remainder open", () => { test("getPrimitiveUnionMatcher: a standalone stringified literal is typed by the member, not its key", () => {
// `"true"` is a string member whose `PatternKey` is `"true"`; inverting the
// key must recover the string, not the boolean that also stringifies to it.
// Arrange // Arrange
const factory = getPrimitiveUnionMatcher<string>(); const factory = getPrimitiveUnionMatcher<"true" | "1">();
// Act // Act
const matcher = factory({ a: () => 1 }, (s) => { const matcher = factory({
// The map's literal keys do not close an open universe, so the true: (s) => {
// remainder stays `string` and the fallback is not redundant. expectTypeOf(s).toEqualTypeOf<"true">();
expectTypeOf(s).toEqualTypeOf<string>(); assert.equal(s, "true");
// A strict pattern fixes one `R` for every handler, so this fallback return 1;
// cannot return its shape; `typeof` is the strongest claim the value },
// makes on its own — any string passes, including `""`. "1": (s) => {
assert.equal(typeof s, "string"); expectTypeOf(s).toEqualTypeOf<"1">();
return 2 as const; assert.equal(s, "1");
return 2;
},
}); });
// Assert // Assert
expectTypeOf(matcher).toEqualTypeOf<(shape: string) => number>(); assert.equal(matcher("true"), 1);
assert.equal(matcher("a"), 1); assert.equal(matcher("1"), 2);
assert.equal(matcher("b"), 2); });
test("getPrimitiveUnionMatcher: a broad universe is rejected", () => {
// Act / Assert — the calls below must not compile
// @ts-expect-error `string` is not a finite union of literals
getPrimitiveUnionMatcher<string>()({ a: () => 1 });
// @ts-expect-error `number` is not a finite union of literals
getPrimitiveUnionMatcher<number>()({ 1: () => 1 });
// @ts-expect-error a template literal is open
getPrimitiveUnionMatcher<`a${string}`>()({ a: () => 1 });
// @ts-expect-error a literal mixed with a template literal is open
getPrimitiveUnionMatcher<"a" | `x-${number}`>()({ a: () => 1 });
// @ts-expect-error `string | boolean` is open because of `string`
getPrimitiveUnionMatcher<string | boolean>()({ true: () => 1 });
// @ts-expect-error the widening factory rejects broad universes too
getPrimitiveUnionMatcherW<string>()({ a: () => 1 });
});
test("getPrimitiveUnionMatcher: a value/stringification collision is rejected", () => {
// Act / Assert — the calls below must not compile
// @ts-expect-error `true` collides with the string `"true"`
getPrimitiveUnionMatcher<"true" | true>()({ true: () => 1 });
// @ts-expect-error `false` collides with the string `"false"`
getPrimitiveUnionMatcher<"false" | false>()({ false: () => 1 });
// @ts-expect-error `null` collides with the string `"null"`
getPrimitiveUnionMatcher<"null" | null>()({ null: () => 1 });
// @ts-expect-error `undefined` collides with the string `"undefined"`
getPrimitiveUnionMatcher<"undefined" | undefined>()({
undefined: () => 1,
});
// @ts-expect-error the number `1` collides with the string `"1"`
getPrimitiveUnionMatcher<1 | "1">()({ 1: () => 1 });
});
test("getPrimitiveUnionMatcher: a collision is rejected beside legal members", () => {
// Act / Assert — the calls below must not compile; the legal members do not
// mask the collision. Each map is otherwise complete, so only the collision
// can make it invalid.
// @ts-expect-error `true` collides with `"true"` beside legal members
getPrimitiveUnionMatcher<"true" | true | "a" | 2>()({
true: () => 1,
a: () => 2,
2: () => 3,
});
// @ts-expect-error `false` collides with `"false"` beside legal members
getPrimitiveUnionMatcher<"false" | false | "a" | 2>()({
false: () => 1,
a: () => 2,
2: () => 3,
});
// @ts-expect-error `null` collides with `"null"` beside legal members
getPrimitiveUnionMatcher<"null" | null | "a" | 2>()({
null: () => 1,
a: () => 2,
2: () => 3,
});
// @ts-expect-error `undefined` collides with `"undefined"` beside legal members
getPrimitiveUnionMatcher<"undefined" | undefined | "a" | 2>()({
undefined: () => 1,
a: () => 2,
2: () => 3,
});
// @ts-expect-error `1` collides with `"1"` beside legal members
getPrimitiveUnionMatcher<1 | "1" | "a" | true>()({
1: () => 1,
a: () => 2,
true: () => 3,
});
});
test('getPrimitiveUnionMatcher: `"true"` and a bare `false` are legal together', () => {
// Arrange — `false` stringifies to `"false"`, so it does not collide with
// the string member `"true"`.
const factory = getPrimitiveUnionMatcher<"true" | false | "a" | 2>();
// Act
const matcher = factory({
true: (s) => {
expectTypeOf(s).toEqualTypeOf<"true">();
assert.equal(s, "true");
return 1;
},
false: (s) => {
expectTypeOf(s).toEqualTypeOf<false>();
assert.equal(s, false);
return 2;
},
a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">();
assert.equal(s, "a");
return 3;
},
2: (s) => {
expectTypeOf(s).toEqualTypeOf<2>();
assert.equal(s, 2);
return 4;
},
});
// Assert
assert.equal(matcher("true"), 1);
assert.equal(matcher(false), 2);
assert.equal(matcher("a"), 3);
assert.equal(matcher(2), 4);
});
test('getPrimitiveUnionMatcher: `"false"` and a bare `true` are legal together', () => {
// Arrange — `true` stringifies to `"true"`, not `"false"`.
const factory = getPrimitiveUnionMatcher<"false" | true | "a" | 2>();
// Act
const matcher = factory({
false: (s) => {
expectTypeOf(s).toEqualTypeOf<"false">();
assert.equal(s, "false");
return 1;
},
true: (s) => {
expectTypeOf(s).toEqualTypeOf<true>();
assert.equal(s, true);
return 2;
},
a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">();
assert.equal(s, "a");
return 3;
},
2: (s) => {
expectTypeOf(s).toEqualTypeOf<2>();
assert.equal(s, 2);
return 4;
},
});
// Assert
assert.equal(matcher("false"), 1);
assert.equal(matcher(true), 2);
assert.equal(matcher("a"), 3);
assert.equal(matcher(2), 4);
});
test('getPrimitiveUnionMatcher: `"null"` and a bare `undefined` are legal together', () => {
// Arrange — `undefined` stringifies to `"undefined"`, not `"null"`.
const factory = getPrimitiveUnionMatcher<"null" | undefined | "a" | 2>();
// Act
const matcher = factory({
null: (s) => {
expectTypeOf(s).toEqualTypeOf<"null">();
assert.equal(s, "null");
return 1;
},
undefined: (s) => {
expectTypeOf(s).toEqualTypeOf<undefined>();
assert.equal(s, undefined);
return 2;
},
a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">();
assert.equal(s, "a");
return 3;
},
2: (s) => {
expectTypeOf(s).toEqualTypeOf<2>();
assert.equal(s, 2);
return 4;
},
});
// Assert
assert.equal(matcher("null"), 1);
assert.equal(matcher(undefined), 2);
assert.equal(matcher("a"), 3);
assert.equal(matcher(2), 4);
});
test('getPrimitiveUnionMatcher: `"undefined"` and a bare `null` are legal together', () => {
// Arrange — `null` stringifies to `"null"`, not `"undefined"`.
const factory = getPrimitiveUnionMatcher<"undefined" | null | "a" | 2>();
// Act
const matcher = factory({
undefined: (s) => {
expectTypeOf(s).toEqualTypeOf<"undefined">();
assert.equal(s, "undefined");
return 1;
},
null: (s) => {
expectTypeOf(s).toEqualTypeOf<null>();
assert.equal(s, null);
return 2;
},
a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">();
assert.equal(s, "a");
return 3;
},
2: (s) => {
expectTypeOf(s).toEqualTypeOf<2>();
assert.equal(s, 2);
return 4;
},
});
// Assert
assert.equal(matcher("undefined"), 1);
assert.equal(matcher(null), 2);
assert.equal(matcher("a"), 3);
assert.equal(matcher(2), 4);
});
test('getPrimitiveUnionMatcher: `"1"` beside other numbers and strings is legal', () => {
// Arrange — the task's example: `"1"` is the string member, `2` and `3`
// are bare numbers, `"4"` is another string. No stringification lands on
// another member.
const factory = getPrimitiveUnionMatcher<"1" | 2 | 3 | "4">();
// Act
const matcher = factory({
"1": (s) => {
expectTypeOf(s).toEqualTypeOf<"1">();
assert.equal(s, "1");
return 1;
},
2: (s) => {
expectTypeOf(s).toEqualTypeOf<2>();
assert.equal(s, 2);
return 2;
},
3: (s) => {
expectTypeOf(s).toEqualTypeOf<3>();
assert.equal(s, 3);
return 3;
},
"4": (s) => {
expectTypeOf(s).toEqualTypeOf<"4">();
assert.equal(s, "4");
return 4;
},
});
// Assert
assert.equal(matcher("1"), 1);
assert.equal(matcher(2), 2);
assert.equal(matcher(3), 3);
assert.equal(matcher("4"), 4);
});
// The maximum legal universe needs one handler per member; the statement count
// is inherent to the case.
// oxlint-disable-next-line max-statements
test("getPrimitiveUnionMatcher: the maximum legal universe is supported", () => {
// Arrange — every Matchable kind, one representation per value, plus extra
// literals. No member's stringification is another member.
type Legal =
| "true"
| false
| null
| undefined
| "1"
| 2
| 3
| "a"
| "b"
| "4";
const factory = getPrimitiveUnionMatcher<Legal>();
// Act
const matcher = factory({
true: (s) => {
expectTypeOf(s).toEqualTypeOf<"true">();
assert.equal(s, "true");
return 1;
},
false: (s) => {
expectTypeOf(s).toEqualTypeOf<false>();
assert.equal(s, false);
return 2;
},
null: (s) => {
expectTypeOf(s).toEqualTypeOf<null>();
assert.equal(s, null);
return 3;
},
undefined: (s) => {
expectTypeOf(s).toEqualTypeOf<undefined>();
assert.equal(s, undefined);
return 4;
},
"1": (s) => {
expectTypeOf(s).toEqualTypeOf<"1">();
assert.equal(s, "1");
return 5;
},
2: (s) => {
expectTypeOf(s).toEqualTypeOf<2>();
assert.equal(s, 2);
return 6;
},
3: (s) => {
expectTypeOf(s).toEqualTypeOf<3>();
assert.equal(s, 3);
return 7;
},
a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">();
assert.equal(s, "a");
return 8;
},
b: (s) => {
expectTypeOf(s).toEqualTypeOf<"b">();
assert.equal(s, "b");
return 9;
},
"4": (s) => {
expectTypeOf(s).toEqualTypeOf<"4">();
assert.equal(s, "4");
return 10;
},
});
// Assert
expectTypeOf(matcher).toEqualTypeOf<(shape: Legal) => number>();
assert.equal(matcher("true"), 1);
assert.equal(matcher(false), 2);
assert.equal(matcher(null), 3);
assert.equal(matcher(undefined), 4);
assert.equal(matcher("1"), 5);
assert.equal(matcher(2), 6);
assert.equal(matcher(3), 7);
assert.equal(matcher("a"), 8);
assert.equal(matcher("b"), 9);
assert.equal(matcher("4"), 10);
});
test("getPrimitiveUnionMatcher: the maximum illegal universe is rejected", () => {
// Arrange — every value paired with its stringification.
type Illegal =
| "true"
| true
| "false"
| false
| "null"
| null
| "undefined"
| undefined
| "1"
| 1;
// Act / Assert — the call below must not compile
// @ts-expect-error every value collides with its stringification
getPrimitiveUnionMatcher<Illegal>()({
true: () => 1,
false: () => 2,
null: () => 3,
undefined: () => 4,
"1": () => 5,
});
});
test("getPrimitiveUnionMatcher: an open universe is rejected with a fallback too", () => {
// Act / Assert — the call below must not compile; a broad universe is
// unsupported whether or not a fallback is supplied.
// @ts-expect-error `string` is not a finite union of literals
getPrimitiveUnionMatcher<string>()({ a: () => 1 }, () => 2);
}); });
test("getPrimitiveUnionMatcherW factory rejects patterns outside its contract", () => { test("getPrimitiveUnionMatcherW factory rejects patterns outside its contract", () => {
@@ -539,16 +907,11 @@ test("getPrimitiveUnionMatcherW factory rejects patterns outside its contract",
// ============================================================================ // ============================================================================
test("getPrimitiveUnionMatcher: an unhandled shape throws without a fallback", () => { test("getPrimitiveUnionMatcher: an unhandled shape throws without a fallback", () => {
// Arrange — an open universe types its handler map as an index signature, // Arrange — a finite universe is exhaustive through the typed API, so the
// so the type system cannot prove the runtime map is exhaustive. // defensive throw is reachable only by widening the factory and dropping a
const handlers: Record<string, (shape: unknown) => number> = { // handler.
a: (shape) => { const bypass: Function = getPrimitiveUnionMatcher<"a" | "b">();
// The raw shape reaches the handler, not the property key's text. const matcher: (shape: "a" | "b") => number = bypass({ a: () => 1 });
assert.equal(shape, "a");
return 1;
},
};
const matcher = getPrimitiveUnionMatcher<string>()(handlers);
// Act / Assert // Act / Assert
assert.equal(matcher("a"), 1); assert.equal(matcher("a"), 1);
@@ -556,17 +919,15 @@ test("getPrimitiveUnionMatcher: an unhandled shape throws without a fallback", (
}); });
test("getPrimitiveUnionMatcher: an unhandled boolean shape throws without a fallback", () => { test("getPrimitiveUnionMatcher: an unhandled boolean shape throws without a fallback", () => {
// Arrange — an `string | boolean` universe widens its handler map to an // Arrange — as above; `true` indexes the map as the property `"true"`, but
// index signature, so the runtime map's exhaustiveness is not provable. // the handler is still called with the boolean.
const handlers: Record<string, (shape: unknown) => number> = { const bypass: Function = getPrimitiveUnionMatcher<true | false>();
true: (shape) => { const matcher: (shape: boolean) => number = bypass({
// `true` indexes the map as the property `"true"`, but the handler true: (shape: boolean) => {
// is still called with the boolean.
assert.equal(shape, true); assert.equal(shape, true);
return 1; return 1;
}, },
}; });
const matcher = getPrimitiveUnionMatcher<string | boolean>()(handlers);
// Act / Assert // Act / Assert
assert.equal(matcher(true), 1); assert.equal(matcher(true), 1);
+52 -10
View File
@@ -3,25 +3,26 @@ import type { Exact } from "type-fest";
import type { import type {
HandlerMap, HandlerMap,
Matchable, Matchable,
Member,
PatternKey, PatternKey,
PatternParam,
PatternReturns, PatternReturns,
RedundantFallback, RedundantFallback,
UnaryFn, UnaryFn,
UniverseGate,
} from "./matcher-shared.ts"; } from "./matcher-shared.ts";
type Handlers<T extends Matchable, R> = { type Handlers<T extends Matchable, R> = {
[K in PatternKey<T>]: UnaryFn<PatternParam<K>, R>; [K in PatternKey<T>]: UnaryFn<Member<T, K>, R>;
}; };
// The fallback is a *second argument*, not a property of the handler map, // The fallback is a *second argument*, not a property of the handler map,
// because its parameter is the remainder `Exclude<T, keyof Handled>` and TypeScript // because its parameter is the remainder `Exclude<T, Member<T, keyof Handled>>` and TypeScript
// fixes a property's contextual type before it infers its sibling keys. A later // 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 // argument, by contrast, is contextually typed from inference on an earlier
// one, so the split is what makes the remainder expressible at all. // one, so the split is what makes the remainder expressible at all.
// See development/library.md. // See development/library.md.
type Fallback<T extends Matchable, Handled, R> = UnaryFn< type Fallback<T extends Matchable, Handled, R> = UnaryFn<
Exclude<T, PatternParam<keyof Handled>>, Exclude<T, Member<T, keyof Handled>>,
R R
>; >;
@@ -44,14 +45,14 @@ type MustBePartial<T extends Matchable, Handled> =
// #1 Handlers (first) -> the exhaustive form and the autocomplete popup // #1 Handlers (first) -> the exhaustive form and the autocomplete popup
// #2 Fallback (last) -> accepts a partial handler map plus a fallback // #2 Fallback (last) -> accepts a partial handler map plus a fallback
interface PrimitiveUnionMatcherStrict<T extends Matchable> { interface PrimitiveUnionMatcherStrict<T extends Matchable> {
<R>(handlers: Handlers<T, R>): UnaryFn<T, R>; <R>(handlers: Handlers<T, R> & UniverseGate<T>): UnaryFn<T, R>;
< <
R, R,
Handled extends Exact<Partial<Handlers<T, R>>, Handled> & Handled extends Exact<Partial<Handlers<T, R>>, Handled> &
MustBePartial<T, Handled>, MustBePartial<T, Handled>,
>( >(
handlers: Handled & Partial<Handlers<T, R>>, handlers: Handled & Partial<Handlers<T, R>> & UniverseGate<T>,
fallback: Fallback<T, Handled, R>, fallback: Fallback<T, Handled, R> & UniverseGate<T>,
): UnaryFn<T, R>; ): UnaryFn<T, R>;
} }
@@ -60,15 +61,15 @@ interface PrimitiveUnionMatcherStrict<T extends Matchable> {
// contextual/autocomplete type. // contextual/autocomplete type.
interface PrimitiveUnionMatcherWidening<T extends Matchable> { interface PrimitiveUnionMatcherWidening<T extends Matchable> {
<P extends Exact<Handlers<T, unknown>, P>>( <P extends Exact<Handlers<T, unknown>, P>>(
handlers: P, handlers: P & UniverseGate<T>,
): UnaryFn<T, PatternReturns<P>>; ): UnaryFn<T, PatternReturns<P>>;
< <
R, R,
Handled extends Exact<Partial<Handlers<T, unknown>>, Handled> & Handled extends Exact<Partial<Handlers<T, unknown>>, Handled> &
MustBePartial<T, Handled>, MustBePartial<T, Handled>,
>( >(
handlers: Handled, handlers: Handled & UniverseGate<T>,
fallback: Fallback<T, Handled, R>, fallback: Fallback<T, Handled, R> & UniverseGate<T>,
): UnaryFn<T, PatternReturns<Handled> | R>; ): UnaryFn<T, PatternReturns<Handled> | R>;
} }
@@ -95,9 +96,50 @@ const dispatch =
shape as never, shape as never,
); );
/**
* Create a matcher for a finite primitive universe, with one common return
* type.
*
* Use it when the value itself is the union (`"yes" | "no"`) and every handler
* returns the same type.
*
* The returned builder takes a handler map keyed by the members; supplying a
* second fallback argument allows a partial map and receives the unhandled
* remainder.
*
* @typeParam T - The finite universe of primitive members to match.
* @returns A builder for the handler map, or the handler map plus a fallback.
* @example
* const matchAnswer = getPrimitiveUnionMatcher<"yes" | "no">();
* const describe = matchAnswer({
* yes: () => "agreed",
* no: () => "declined",
* });
* describe("yes"); // "agreed"
*/
export const getPrimitiveUnionMatcher = < export const getPrimitiveUnionMatcher = <
T extends Matchable, T extends Matchable,
>(): PrimitiveUnionMatcherStrict<T> => dispatch; >(): PrimitiveUnionMatcherStrict<T> => dispatch;
/**
* Create a matcher for a finite primitive universe whose return type is the
* union of every handler's return type.
*
* Use it when the value itself is the union (`"yes" | "no"`) and the handlers
* return different types. The `W` (widening) counterpart of
* {@link getPrimitiveUnionMatcher}; the universe constraint and the optional
* fallback are identical.
*
* @typeParam T - The finite universe of primitive members to match.
* @returns A builder for the handler map, or the handler map plus a fallback.
* @example
* const matchReply = getPrimitiveUnionMatcherW<"yes" | "no">();
* const reply = matchReply({
* yes: () => 1,
* no: () => "declined",
* });
* // reply: (shape: "yes" | "no") => number | string
*/
export const getPrimitiveUnionMatcherW = < export const getPrimitiveUnionMatcherW = <
T extends Matchable, T extends Matchable,
>(): PrimitiveUnionMatcherWidening<T> => dispatch; >(): PrimitiveUnionMatcherWidening<T> => dispatch;
+991 -25
View File
File diff suppressed because it is too large. Load diff
+90 -25
View File
@@ -1,13 +1,14 @@
import type { Exact, UnknownRecord } from "type-fest"; import type { Exact, SetRequired, UnknownRecord } from "type-fest";
import type { import type {
HandlerMap, HandlerMap,
Matchable, Matchable,
Member,
PatternKey, PatternKey,
PatternParam,
PatternReturns, PatternReturns,
RedundantFallback, RedundantFallback,
UnaryFn, UnaryFn,
UniverseGate,
} from "./matcher-shared.ts"; } from "./matcher-shared.ts";
// A tagged union is discriminated by one property whose values are the tags (a // A tagged union is discriminated by one property whose values are the tags (a
@@ -17,8 +18,8 @@ import type {
// to write a handler under, and `bigint` is not a property key. // to write a handler under, and `bigint` is not a property key.
// The discriminant values of `T` under `K`. `Extract` keeps the finite literal // The discriminant values of `T` under `K`. `Extract` keeps the finite literal
// tags and leaves a widened `string`/`number` as itself, so an open universe // tags and leaves a widened `string`/`number` as itself; a broad tag is then
// keeps an open fallback. // rejected by the universe gate.
type Tags<T extends object, K extends keyof T> = Extract<T[K], Matchable>; 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>()` // The keys of `T` that can act as a discriminant. `getTaggedUnionMatcher<T>()`
@@ -27,33 +28,49 @@ type Discriminated<T extends object> = {
[K in keyof T]: T[K] extends Matchable ? K : never; [K in keyof T]: T[K] extends Matchable ? K : never;
}[keyof T]; }[keyof T];
// The member(s) of `T` tagged `V`. `Extract` distributes over the union, so a // 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. // 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 // Keyed by `PatternKey`, so `boolean`/`null`/`undefined` tags can key a mapped
// type; `PatternParam` inverts the projection to recover the member. // 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> = { type MapTaggedUnion<T extends object, K extends keyof T> = {
[P in PatternKey<Tags<T, K>>]: Extract<T, Record<K, PatternParam<P>>>; [P in PatternKey<Tags<T, K>>]: Narrowed<T, K, Member<Tags<T, K>, P>>;
}; };
type Handlers<T extends object, K extends keyof T, R> = { type Handlers<T extends object, K extends keyof T, R> = {
[P in PatternKey<Tags<T, K>>]: UnaryFn<MapTaggedUnion<T, K>[P], R>; [P in PatternKey<Tags<T, K>>]: UnaryFn<MapTaggedUnion<T, K>[P], R>;
}; };
// The members `Handled` covers. Mapping over the projected keys keeps every // The tag values whose `PatternKey` is handled.
// index within `MapTaggedUnion`'s keys, and the conditional drops a stray key type HandledTags<T extends object, K extends keyof T, Handled> = Member<
// outside `T` so it cannot widen the remainder. The remainder is Tags<T, K>,
// `Exclude<T, …>`, mirroring the primitive-union matcher's keyof Handled
// `Exclude<T, PatternParam<keyof Handled>>`. >;
type HandledMembers<T extends object, K extends keyof T, Handled> = {
[P in PatternKey<Tags<T, K>>]: P extends keyof Handled
? MapTaggedUnion<T, K>[P]
: never;
}[PatternKey<Tags<T, K>>];
// The fallback is a *second argument*, not a property of the handler map, so // The fallback is a *second argument*, not a property of the handler map, so
// its parameter can be the remainder the map left open. See development/library.md. // 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< type Fallback<T extends object, K extends keyof T, Handled, R> = UnaryFn<
Exclude<T, HandledMembers<T, K, Handled>>, Narrowed<T, K, Exclude<Tags<T, K>, HandledTags<T, K, Handled>>>,
R R
>; >;
@@ -71,14 +88,16 @@ type MustBePartial<T extends object, K extends keyof T, Handled> =
// #1 Handlers (first) -> the exhaustive form and the autocomplete popup // #1 Handlers (first) -> the exhaustive form and the autocomplete popup
// #2 Fallback (last) -> accepts a partial handler map plus a fallback // #2 Fallback (last) -> accepts a partial handler map plus a fallback
interface TaggedUnionMatcherStrict<T extends object, K extends keyof T> { interface TaggedUnionMatcherStrict<T extends object, K extends keyof T> {
<R>(handlers: Handlers<T, K, R>): UnaryFn<T, R>; <R>(handlers: Handlers<T, K, R> & UniverseGate<Tags<T, K>>): UnaryFn<T, R>;
< <
R, R,
Handled extends Exact<Partial<Handlers<T, K, R>>, Handled> & Handled extends Exact<Partial<Handlers<T, K, R>>, Handled> &
MustBePartial<T, K, Handled>, MustBePartial<T, K, Handled>,
>( >(
handlers: Handled & Partial<Handlers<T, K, R>>, handlers: Handled &
fallback: Fallback<T, K, Handled, R>, Partial<Handlers<T, K, R>> &
UniverseGate<Tags<T, K>>,
fallback: Fallback<T, K, Handled, R> & UniverseGate<Tags<T, K>>,
): UnaryFn<T, R>; ): UnaryFn<T, R>;
} }
@@ -87,15 +106,15 @@ interface TaggedUnionMatcherStrict<T extends object, K extends keyof T> {
// contextual/autocomplete type. // contextual/autocomplete type.
interface TaggedUnionMatcherWidening<T extends object, K extends keyof T> { interface TaggedUnionMatcherWidening<T extends object, K extends keyof T> {
<P extends Exact<Handlers<T, K, unknown>, P>>( <P extends Exact<Handlers<T, K, unknown>, P>>(
handlers: P, handlers: P & UniverseGate<Tags<T, K>>,
): UnaryFn<T, PatternReturns<P>>; ): UnaryFn<T, PatternReturns<P>>;
< <
R, R,
Handled extends Exact<Partial<Handlers<T, K, unknown>>, Handled> & Handled extends Exact<Partial<Handlers<T, K, unknown>>, Handled> &
MustBePartial<T, K, Handled>, MustBePartial<T, K, Handled>,
>( >(
handlers: Handled, handlers: Handled & UniverseGate<Tags<T, K>>,
fallback: Fallback<T, K, Handled, R>, fallback: Fallback<T, K, Handled, R> & UniverseGate<Tags<T, K>>,
): UnaryFn<T, PatternReturns<Handled> | R>; ): UnaryFn<T, PatternReturns<Handled> | R>;
} }
@@ -133,10 +152,56 @@ const dispatch =
); );
}; };
/**
* Create a matcher for a discriminated union, with one common return type.
*
* Use it when the value is an object discriminated by a property
* (`{ kind: "circle" } | { kind: "square" }`) and every handler returns the
* same type.
*
* The first call fixes the union `T`; the returned function takes the
* discriminant property's name (`K`, restricted to properties whose values are
* tags), and that returns the handler-map builder. Supplying a second fallback
* argument to the builder allows a partial map and receives the members whose
* tag was not handled. A `boolean`, `null` or `undefined` tag is keyed by its
* stringified form (`true` -> `"true"`); see README § Caveats.
*
* @typeParam T - The discriminated-union type to match.
* @returns A function that takes the discriminant property's name.
* @example
* type Shape =
* | { kind: "circle"; radius: number }
* | { kind: "square"; side: number };
*
* const matchShape = getTaggedUnionMatcher<Shape>()("kind");
* const area = matchShape({
* circle: (s) => Math.PI * s.radius ** 2,
* square: (s) => s.side ** 2,
* });
*/
export const getTaggedUnionMatcher = < export const getTaggedUnionMatcher = <
T extends object, T extends object,
>(): TaggedUnionMatcherFactory<T> => dispatch; >(): TaggedUnionMatcherFactory<T> => dispatch;
/**
* Create a matcher for a discriminated union whose return type is the union of
* every handler's return type.
*
* Use it when the value is a discriminated object and the handlers return
* different types. The `W` (widening) counterpart of
* {@link getTaggedUnionMatcher}; the curried key step and the optional fallback
* are identical.
*
* @typeParam T - The discriminated-union type to match.
* @returns A function that takes the discriminant property's name.
* @example
* const matchShape = getTaggedUnionMatcherW<Shape>()("kind");
* const describe = matchShape({
* circle: () => "round",
* square: () => 4,
* });
* // describe: (shape: Shape) => string | number
*/
export const getTaggedUnionMatcherW = < export const getTaggedUnionMatcherW = <
T extends object, T extends object,
>(): TaggedUnionMatcherWideningFactory<T> => dispatch; >(): TaggedUnionMatcherWideningFactory<T> => dispatch;