30 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
20 changed files with 1379 additions and 537 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
+28 -1
View File
@@ -7,6 +7,30 @@ 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 ## [0.7.0] - 2026-09-23
- reject broad universes (`string`, `number`, template literals) and - reject broad universes (`string`, `number`, template literals) and
@@ -82,7 +106,10 @@ 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.7.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.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
+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
+101 -2
View File
@@ -21,7 +21,93 @@ 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
@@ -40,9 +126,22 @@ Yet to be implemented
- **`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 @cancelled
→ broad universes are rejected; the rejection is covered by the broad-universe gate tests
✔ Type hole when using broad types like string as a universe @high @done
→ decided: broad universes are rejected at the factory, so the exhaustive overload can always be proven
→ value/stringification collisions (`true | "true"`, `1 | "1"`) are rejected too; handler params are typed by `Member<T, K>`
→ see development/library.md § Supported universes
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
+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)
+64 -13
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
@@ -145,6 +176,10 @@ inversion: the handler parameter is the member(s) of `T` whose `PatternKey` is
object satisfy the exhaustive overload and reaches the `dispatch` throw. object satisfy the exhaustive overload and reaches the `dispatch` throw.
Rejecting at the boundary avoids threading an open/closed branch through Rejecting at the boundary avoids threading an open/closed branch through
`Handlers`, `Fallback` and `MustBePartial`. `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 - **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 handler gets the union), but the API is one handler per member; rejecting
keeps `Member` a singleton and the remainder exact. keeps `Member` a singleton and the remainder exact.
@@ -163,7 +198,9 @@ Extract<T, Stringified<T>>` catches numeric collisions too.
non-self-referential parameter type (`{ [K in keyof H]: … R … }` gives non-self-referential parameter type (`{ [K in keyof H]: … R … }` gives
`unknown`); an F-bounded guard referencing `keyof Handled` in `Handled`'s own `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 constraint sees the constraint, not the map; all handlers share one `R` (only
the fallback widens it); `IsLiteral` is the finite-literal predicate. 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 - **`Member<T, K>` without the gate:** sound, but a colliding handler gets a
union and `1 | "1"` stays one runtime key. union and `1 | "1"` stays one runtime key.
- **A round-trip injectivity gate** (`IsEqual<T, PatternParam<PatternKey<T>>>`): - **A round-trip injectivity gate** (`IsEqual<T, PatternParam<PatternKey<T>>>`):
@@ -208,16 +245,27 @@ 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` / `Member` 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
@@ -228,10 +276,13 @@ The key is a separate call because `K` is inferred from its literal argument and
#### 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. A tag colliding with its soundness hole: they select a single runtime key, so one handler receiving
stringification (`true | "true"`) is rejected by the universe gate — see their union is the only correct behavior — the key is simply not a
§ Supported universes. 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
+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.7.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,
+5 -4
View File
@@ -1,3 +1,4 @@
/* c8 ignore start -- types only: the module has no runtime image to cover */
import type { IsLiteral, IsNever, ValueOf } from "type-fest"; 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
@@ -62,11 +63,11 @@ export type Collisions<T> = Extract<T, Stringified<T>>;
// universe is a finite union of literals with no value/stringification // universe is a finite union of literals with no value/stringification
// collision: only then can `PatternKey` be inverted unambiguously. // collision: only then can `PatternKey` be inverted unambiguously.
export type UnsupportedReason<T extends Matchable> = export type UnsupportedReason<T extends Matchable> =
IsLiteral<PatternKey<T>> extends false IsLiteral<PatternKey<T>> extends true
? "broad types like string, number and template literals are not supported" ? [Collisions<T>] extends [never]
: [Collisions<T>] extends [never]
? never ? never
: `a value and its stringification collide. Value is "${Collisions<T> & string}"`; : `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 // The diagnostic for an unsupported universe. Extends `HandlerMap` so the
// implementation's `handlers: HandlerMap` stays assignable when the gate is // implementation's `handlers: HandlerMap` stays assignable when the gate is
+2
View File
@@ -528,6 +528,8 @@ test("getPrimitiveUnionMatcher: a broad universe is rejected", () => {
getPrimitiveUnionMatcher<number>()({ 1: () => 1 }); getPrimitiveUnionMatcher<number>()({ 1: () => 1 });
// @ts-expect-error a template literal is open // @ts-expect-error a template literal is open
getPrimitiveUnionMatcher<`a${string}`>()({ a: () => 1 }); 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` // @ts-expect-error `string | boolean` is open because of `string`
getPrimitiveUnionMatcher<string | boolean>()({ true: () => 1 }); getPrimitiveUnionMatcher<string | boolean>()({ true: () => 1 });
// @ts-expect-error the widening factory rejects broad universes too // @ts-expect-error the widening factory rejects broad universes too
+41
View File
@@ -96,9 +96,50 @@ const dispatch =
shape as never, shape as never,
); );
/**
* Create a matcher for a finite primitive universe, with one common return
* type.
*
* Use it when the value itself is the union (`"yes" | "no"`) and every handler
* returns the same type.
*
* The returned builder takes a handler map keyed by the members; supplying a
* second fallback argument allows a partial map and receives the unhandled
* remainder.
*
* @typeParam T - The finite universe of primitive members to match.
* @returns A builder for the handler map, or the handler map plus a fallback.
* @example
* const matchAnswer = getPrimitiveUnionMatcher<"yes" | "no">();
* const describe = matchAnswer({
* yes: () => "agreed",
* no: () => "declined",
* });
* describe("yes"); // "agreed"
*/
export const getPrimitiveUnionMatcher = < export const getPrimitiveUnionMatcher = <
T extends Matchable, T extends Matchable,
>(): PrimitiveUnionMatcherStrict<T> => dispatch; >(): PrimitiveUnionMatcherStrict<T> => dispatch;
/**
* Create a matcher for a finite primitive universe whose return type is the
* union of every handler's return type.
*
* Use it when the value itself is the union (`"yes" | "no"`) and the handlers
* return different types. The `W` (widening) counterpart of
* {@link getPrimitiveUnionMatcher}; the universe constraint and the optional
* fallback are identical.
*
* @typeParam T - The finite universe of primitive members to match.
* @returns A builder for the handler map, or the handler map plus a fallback.
* @example
* const matchReply = getPrimitiveUnionMatcherW<"yes" | "no">();
* const reply = matchReply({
* yes: () => 1,
* no: () => "declined",
* });
* // reply: (shape: "yes" | "no") => number | string
*/
export const getPrimitiveUnionMatcherW = < export const getPrimitiveUnionMatcherW = <
T extends Matchable, T extends Matchable,
>(): PrimitiveUnionMatcherWidening<T> => dispatch; >(): PrimitiveUnionMatcherWidening<T> => dispatch;
+516 -1
View File
@@ -892,6 +892,434 @@ test("getTaggedUnionMatcher: the maximum illegal tag universe is rejected", () =
}); });
}); });
// ============================================================================
// Property unions
// ============================================================================
test("property union: narrows each handler", () => {
// Arrange — one shape, not a union of variants; `color` is a union.
interface Paint {
readonly color: "red" | "green" | "blue";
readonly value: number;
}
const factory = getTaggedUnionMatcher<Paint>()("color");
// Act
const describe = factory({
red: (s) => {
expectTypeOf(s).toEqualTypeOf<{
readonly color: "red";
readonly value: number;
}>();
assert.equal(s.color, "red");
return s.value;
},
green: (s) => {
expectTypeOf(s).toEqualTypeOf<{
readonly color: "green";
readonly value: number;
}>();
assert.equal(s.color, "green");
return s.value;
},
blue: (s) => {
expectTypeOf(s).toEqualTypeOf<{
readonly color: "blue";
readonly value: number;
}>();
assert.equal(s.color, "blue");
return s.value;
},
});
// Assert
expectTypeOf(describe).toEqualTypeOf<(shape: Paint) => number>();
assert.equal(describe({ color: "red", value: 1 }), 1);
assert.equal(describe({ color: "green", value: 2 }), 2);
assert.equal(describe({ color: "blue", value: 3 }), 3);
});
test("property union: a fallback receives the unhandled values", () => {
// Arrange
interface Paint {
color: "red" | "green" | "blue";
value: number;
}
const factory = getTaggedUnionMatcher<Paint>()("color");
// Act
const describe = factory(
{
red: (s) => {
expectTypeOf(s).toEqualTypeOf<{
color: "red";
value: number;
}>();
assert.equal(s.color, "red");
return 1 as const;
},
},
(s) => {
expectTypeOf(s).toEqualTypeOf<{
color: "green" | "blue";
value: number;
}>();
assert.ok(s.color === "green" || s.color === "blue");
return 2 as const;
},
);
// Assert
expectTypeOf(describe).toEqualTypeOf<(shape: Paint) => 1 | 2>();
assert.equal(describe({ color: "red", value: 1 }), 1);
assert.equal(describe({ color: "green", value: 2 }), 2);
assert.equal(describe({ color: "blue", value: 3 }), 2);
});
test("property union: a numeric property narrows each handler", () => {
// Arrange
interface Version {
code: 1 | 2 | 3;
value: number;
}
const factory = getTaggedUnionMatcher<Version>()("code");
// Act
const describe = factory({
1: (s) => {
expectTypeOf(s).toEqualTypeOf<{ code: 1; value: number }>();
assert.equal(s.code, 1);
return 1;
},
2: (s) => {
expectTypeOf(s).toEqualTypeOf<{ code: 2; value: number }>();
assert.equal(s.code, 2);
return 2;
},
3: (s) => {
expectTypeOf(s).toEqualTypeOf<{ code: 3; value: number }>();
assert.equal(s.code, 3);
return 3;
},
});
// Assert
expectTypeOf(describe).toEqualTypeOf<(shape: Version) => number>();
assert.equal(describe({ code: 1, value: 10 }), 1);
assert.equal(describe({ code: 2, value: 20 }), 2);
assert.equal(describe({ code: 3, value: 30 }), 3);
});
test("property union: an optional property is required for a defined tag", () => {
// Arrange — `type?` is `"x" | undefined`; both handlers must be typed,
// and matching `"x"` proves the key is present, so `type` becomes required.
interface Maybe {
type?: "x";
value: number;
}
const factory = getTaggedUnionMatcher<Maybe>()("type");
// Act
const describe = factory({
x: (s) => {
expectTypeOf(s).toEqualTypeOf<{ type: "x"; value: number }>();
assert.equal(s.type, "x");
return 1;
},
undefined: (s) => {
// The input only allows absence (exactOptionalPropertyTypes), so
// the `undefined` tag narrows the key to never-present.
expectTypeOf(s).toEqualTypeOf<{ type?: never; value: number }>();
assert.equal(s.type, undefined);
return 2;
},
});
// Assert
expectTypeOf(describe).toEqualTypeOf<(shape: Maybe) => number>();
assert.equal(describe({ type: "x", value: 1 }), 1);
assert.equal(describe({ value: 2 }), 2);
});
test("property union: an optional fallback receives the undefined tag", () => {
// Arrange
interface Maybe {
type?: "x";
value: number;
}
const factory = getTaggedUnionMatcher<Maybe>()("type");
// Act
const describe = factory(
{
x: (s) => {
expectTypeOf(s).toEqualTypeOf<{ type: "x"; value: number }>();
assert.equal(s.type, "x");
return 1 as const;
},
},
(s) => {
expectTypeOf(s).toEqualTypeOf<{
type?: never;
value: number;
}>();
assert.equal(s.type, undefined);
return 2 as const;
},
);
// Assert
expectTypeOf(describe).toEqualTypeOf<(shape: Maybe) => 1 | 2>();
assert.equal(describe({ type: "x", value: 1 }), 1);
assert.equal(describe({ value: 2 }), 2);
});
test("property union: explicit `undefined` stays distinct from absence", () => {
// Arrange — `type?: "x" | undefined` admits an explicit `undefined`, unlike
// the bare optional above; the two representations must not be conflated.
interface Explicit {
type?: "x" | undefined;
value: number;
}
const factory = getTaggedUnionMatcher<Explicit>()("type");
// Act
const describe = factory({
x: (s) => {
expectTypeOf(s).toEqualTypeOf<{ type: "x"; value: number }>();
assert.equal(s.type, "x");
return 1;
},
undefined: (s) => {
expectTypeOf(s).toEqualTypeOf<{
type?: undefined;
value: number;
}>();
assert.equal(s.type, undefined);
return 2;
},
});
// Assert
expectTypeOf(describe).toEqualTypeOf<(shape: Explicit) => number>();
assert.equal(describe({ type: "x", value: 1 }), 1);
assert.equal(describe({ type: undefined, value: 2 }), 2);
assert.equal(describe({ value: 3 }), 2);
});
test("property union: a union-valued member is split, not dropped", () => {
// A member whose tag is itself a union sits beside a singleton-tag member.
// Selecting members with `Extract<T, Record<K, V>>` keeps only the
// singleton; the distributive narrowing must keep both sides of the
// union-valued member.
// Arrange
type Mixed = { kind: "a"; a: number } | { kind: "a" | "b"; b: number };
const factory = getTaggedUnionMatcher<Mixed>()("kind");
// Act
const describe = factory({
a: (s) => {
expectTypeOf(s).toEqualTypeOf<
{ kind: "a"; a: number } | { kind: "a"; b: number }
>();
assert.equal(s.kind, "a");
return 1;
},
b: (s) => {
expectTypeOf(s).toEqualTypeOf<{ kind: "b"; b: number }>();
assert.equal(s.kind, "b");
return 2;
},
});
// Assert
expectTypeOf(describe).toEqualTypeOf<(shape: Mixed) => number>();
assert.equal(describe({ kind: "a", a: 1 }), 1);
assert.equal(describe({ kind: "a", b: 2 }), 1);
assert.equal(describe({ kind: "b", b: 3 }), 2);
});
test("property union: getTaggedUnionMatcherW widens the returns", () => {
// Arrange
interface Paint {
color: "red" | "green" | "blue";
value: number;
}
const factory = getTaggedUnionMatcherW<Paint>()("color");
// Act
const describe = factory({
red: (s) => {
expectTypeOf(s).toEqualTypeOf<{ color: "red"; value: number }>();
assert.equal(s.color, "red");
return "r" as const;
},
green: (s) => {
expectTypeOf(s).toEqualTypeOf<{ color: "green"; value: number }>();
assert.equal(s.color, "green");
return 1 as const;
},
blue: (s) => {
expectTypeOf(s).toEqualTypeOf<{ color: "blue"; value: number }>();
assert.equal(s.color, "blue");
return true as const;
},
});
// Assert
expectTypeOf(describe).toEqualTypeOf<(shape: Paint) => "r" | 1 | true>();
assert.equal(describe({ color: "red", value: 1 }), "r");
assert.equal(describe({ color: "green", value: 2 }), 1);
assert.equal(describe({ color: "blue", value: 3 }), true);
});
test("property union: enforces its contract", () => {
// Arrange
interface Paint {
color: "red" | "green" | "blue";
value: number;
}
const factory = getTaggedUnionMatcher<Paint>()("color");
// Act / Assert — the calls below must not compile
factory({
red: () => 1,
green: () => 2,
blue: () => 3,
// @ts-expect-error `yellow` is not a value of `color`
yellow: () => 4,
});
// @ts-expect-error a gap without a fallback is not exhaustive
factory({ red: () => 1 });
// @ts-expect-error a fallback is redundant once every value is handled
factory({ red: () => 1, green: () => 2, blue: () => 3 }, () => 0);
});
test("property union: a broad value in the union is rejected", () => {
// Arrange — the property is a union, but not a finite union of literals: a
// template literal cannot be proven exhaustive.
interface Template {
kind: "a" | `x-${number}`;
a: number;
}
// Act / Assert — the calls below must not compile
// @ts-expect-error a template literal is not a finite literal
getTaggedUnionMatcher<Template>()("kind")({ a: () => 1 });
// @ts-expect-error the widening factory rejects broad values too
getTaggedUnionMatcherW<Template>()("kind")({ a: () => 1 });
});
test("property union: a value/stringification collision is rejected", () => {
// Arrange — one property holding both a value and its stringification; both
// would key the same handler.
interface BooleanColliding {
kind: "true" | true;
a: number;
}
interface NumericColliding {
kind: "1" | 1;
a: number;
}
// Act / Assert — the calls below must not compile
// @ts-expect-error `true` collides with the string tag `"true"`
getTaggedUnionMatcher<BooleanColliding>()("kind")({ true: () => 1 });
// @ts-expect-error the number tag `1` collides with the string tag `"1"`
getTaggedUnionMatcher<NumericColliding>()("kind")({ "1": () => 1 });
// @ts-expect-error the widening factory rejects the collision too
getTaggedUnionMatcherW<BooleanColliding>()("kind")({ true: () => 1 });
});
test("property union: a collision is rejected beside legal members", () => {
// Arrange — the map is otherwise complete, so only the collision can make
// it invalid.
interface Colliding {
kind: "true" | true | "a";
a: number;
}
// Act / Assert — the call below must not compile
// @ts-expect-error `true` collides with the string tag `"true"`
getTaggedUnionMatcher<Colliding>()("kind")({ true: () => 1, a: () => 2 });
});
// ============================================================================
// Duplicate tags
// ============================================================================
// A tag is a discriminant only while it is unique across the union. Two members
// may still share one: the tag set `Tags<T, K>` dedupes, so a single handler is
// exhaustive for both and receives their union. This is the documented known
// issue (development/library.md § Tagged-union matcher); the tests below pin the
// behavior so it cannot change silently.
test("duplicate tag: members sharing a tag collapse to one handler", () => {
// Arrange — `kind` is not a true discriminant: both members carry `"a"`.
interface First {
readonly kind: "a";
readonly first: number;
}
interface Second {
readonly kind: "a";
readonly second: string;
}
type Clashing = First | Second;
const factory = getTaggedUnionMatcher<Clashing>()("kind");
// Act
const pick = factory({
a: (s) => {
// The shared tag cannot be split, so the handler sees both members.
expectTypeOf(s).toEqualTypeOf<First | Second>();
assert.equal(s.kind, "a");
return "first" in s ? s.first : s.second.length;
},
});
// Assert — the one `a` key is exhaustive and both members reach it.
expectTypeOf(pick).toEqualTypeOf<(shape: Clashing) => number>();
assert.equal(pick({ kind: "a", first: 1 }), 1);
assert.equal(pick({ kind: "a", second: "abc" }), 3);
});
test("duplicate tag: handling one tag consumes every member that shares it", () => {
// Arrange — `"a"` selects two members; `"c"` selects one.
type Mixed =
| { readonly kind: "a"; readonly a: number }
| { readonly kind: "a"; readonly b: string }
| { readonly kind: "c"; readonly c: boolean };
const factory = getTaggedUnionMatcher<Mixed>()("kind");
// Act
const describe = factory(
{
a: (s) => {
expectTypeOf(s).toEqualTypeOf<
| { readonly kind: "a"; readonly a: number }
| { readonly kind: "a"; readonly b: string }
>();
return 1 as const;
},
},
(s) => {
// Handling `"a"` removes both of its members, not just one.
expectTypeOf(s).toEqualTypeOf<{
readonly kind: "c";
readonly c: boolean;
}>();
assert.equal(s.kind, "c");
return 2 as const;
},
);
// Assert
expectTypeOf(describe).toEqualTypeOf<(shape: Mixed) => 1 | 2>();
assert.equal(describe({ kind: "a", a: 1 }), 1);
assert.equal(describe({ kind: "a", b: "x" }), 1);
assert.equal(describe({ kind: "c", c: true }), 2);
});
// ============================================================================ // ============================================================================
// Dispatch — runtime behavior // Dispatch — runtime behavior
// ============================================================================ // ============================================================================
@@ -935,6 +1363,7 @@ interface LabelsProbe {
readonly tail?: string; readonly tail?: string;
readonly typeName?: string; readonly typeName?: string;
readonly typeSource?: string; readonly typeSource?: string;
readonly key?: string;
} }
const labelsFor = ({ const labelsFor = ({
@@ -944,6 +1373,7 @@ const labelsFor = ({
tail = "", tail = "",
typeName = "Shape", typeName = "Shape",
typeSource = SHAPE_SOURCE, typeSource = SHAPE_SOURCE,
key = "kind",
}: LabelsProbe): Promise<readonly string[]> => { }: LabelsProbe): Promise<readonly string[]> => {
const session = new LspSession(REPO_ROOT); const session = new LspSession(REPO_ROOT);
const target: CompletionTarget = { const target: CompletionTarget = {
@@ -951,7 +1381,7 @@ const labelsFor = ({
source: [ source: [
`import { ${factory} } from "./index.ts";`, `import { ${factory} } from "./index.ts";`,
typeSource, typeSource,
`const m = ${factory}<${typeName}>()("kind")({`, `const m = ${factory}<${typeName}>()("${key}")({`,
body, body,
`}${tail});`, `}${tail});`,
"", "",
@@ -1038,6 +1468,91 @@ test("autocomplete: boolean and nullish tags are offered by name", () => {
}); });
}); });
// A single shape whose property is a union: the popup is still keyed by the
// property's values, not by the members of a union type.
const PALETTE_SOURCE = `interface Palette { color: "red" | "green" | "blue"; value: number }`;
const FLAG_SOURCE = `interface Flag { kind: true | false | null | undefined; a: number }`;
test("property union: autocomplete offers the property's values", () => {
// Arrange
const name = "property_union_fresh";
// Act
const labels = labelsFor({
name,
factory: "getTaggedUnionMatcher",
typeName: "Palette",
typeSource: PALETTE_SOURCE,
key: "color",
body: " /*COMPLETE*/",
});
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["blue", "green", "red"]);
});
});
test("property union: autocomplete drops a handled value", () => {
// Arrange
const name = "property_union_after_key";
// Act
const labels = labelsFor({
name,
factory: "getTaggedUnionMatcher",
typeName: "Palette",
typeSource: PALETTE_SOURCE,
key: "color",
body: " red: () => 1,\n /*COMPLETE*/",
});
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["blue", "green"]);
});
});
test("property union: autocomplete makes the remaining values optional with a fallback", () => {
// Arrange
const name = "property_union_with_fallback";
// Act
const labels = labelsFor({
name,
factory: "getTaggedUnionMatcher",
typeName: "Palette",
typeSource: PALETTE_SOURCE,
key: "color",
body: " /*COMPLETE*/",
tail: ", () => 0",
});
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["blue?", "green?", "red?"]);
});
});
test("property union: autocomplete offers boolean and nullish values by name", () => {
// Arrange
const name = "property_union_boolean_nullish";
// Act
const labels = labelsFor({
name,
factory: "getTaggedUnionMatcher",
typeName: "Flag",
typeSource: FLAG_SOURCE,
body: " /*COMPLETE*/",
});
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["false", "null", "true", "undefined"]);
});
});
// `Mixed` has two common keys, but `id`'s value is not a tag, so only `kind` // `Mixed` has two common keys, but `id`'s value is not a tag, so only `kind`
// may serve as the discriminant. // may serve as the discriminant.
const MIXED_SOURCE = `type Mixed = { id: Date; kind: "a"; a: number } | { id: Date; kind: "b"; b: number };`; const MIXED_SOURCE = `type Mixed = { id: Date; kind: "a"; a: number } | { id: Date; kind: "b"; b: number };`;
+78 -17
View File
@@ -1,4 +1,4 @@
import type { Exact, UnknownRecord } from "type-fest"; import type { Exact, SetRequired, UnknownRecord } from "type-fest";
import type { import type {
HandlerMap, HandlerMap,
@@ -28,34 +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; `Member` inverts the projection against the tag set to recover the // type; `Member` inverts the projection against the tag set to recover the tag.
// member. 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, Member<Tags<T, K>, 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, Member<T, 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 uncovered. 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
>; >;
@@ -137,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;