Compare commits
18
Commits
0.8.0
..
d1963b0329
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1963b0329 | ||
|
|
0bde38d6e4 | ||
|
|
38484aa16d | ||
|
|
c878e60ff2 | ||
|
|
c8bee95088 | ||
|
|
5ddbbdc183 | ||
|
|
9c9468fa3c | ||
|
|
f485b1bae2 | ||
|
|
acf9d06ddd | ||
|
|
eba60f6569 | ||
|
|
52ce655d8e | ||
|
|
76327a8c47 | ||
|
|
d7004c0f71 | ||
|
|
69231eb9ac | ||
|
|
7ff371c569 | ||
|
|
0339a493a2 | ||
|
|
2c856f28e3 | ||
|
|
4260a4732c |
No files matched your search
@@ -77,6 +77,9 @@ jobs:
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- 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
|
||||
# Publish this tag's coverage to the self-hosted pages server,
|
||||
# served read-only at
|
||||
|
||||
+14
-1
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [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.
|
||||
@@ -94,7 +106,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- basic setup
|
||||
|
||||
[Unreleased]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.8.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
|
||||
|
||||
+3
-2
@@ -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 fix` | manual | Auto-resolve fixable issues (lint, format) | ~3s |
|
||||
| `npm run maintain` | manual / CI (advisory) | `maintain:knip` + `maintain:outdated` (whole-project + network scans) | ~10s |
|
||||
| CI build (auto) | on push to `main` / tag | `build` job (build + correctness + packaging) — see [.gitea/workflows/ci.yml](./.gitea/workflows/ci.yml) | ~30s+ |
|
||||
| CI build (auto) | on push to `main` / tag | `build` job (build + correctness + coverage + packaging) — see [.gitea/workflows/ci.yml](./.gitea/workflows/ci.yml) | ~30s+ |
|
||||
| CI 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 |
|
||||
|
||||
@@ -125,7 +125,8 @@ CI step. Pick the prefix that matches the script's lifecycle:
|
||||
`npm run fix`; the diff is the review surface.
|
||||
- `test:*` — test scripts. `test` is the canonical entry point (`check:tsc` +
|
||||
unit tests); `test:unit` skips the typecheck for fast local iteration;
|
||||
`test:ci` adds c8 coverage.
|
||||
`test: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`.
|
||||
- `maintain:*` — advisory repo-maintenance scans: read-only, but whole-project
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 tmu
|
||||
Copyright (c) 2026 tmu
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -21,7 +21,93 @@ for the design decisions and [Caveats](#caveats) for the limits.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -40,9 +126,22 @@ Yet to be implemented
|
||||
- **`NaN` and `-0` cannot be matched specifically.** They have no literal type,
|
||||
so both stay part of `number`.
|
||||
|
||||
### Why open universes are rejected
|
||||
|
||||
An open universe — one carrying a broad member, as in
|
||||
`type Units = "s" | "ms" | "min" | (string & {})` — is not a dispatch concern.
|
||||
If the values arrive from outside the program, parse them at the boundary down
|
||||
to a finite union and match the narrowed result; the openness never reaches the
|
||||
matcher. If the domain is genuinely extensible, the right shape is a runtime
|
||||
`Map` of handlers, where "no handler" is a lookup, not a pattern. Either way an
|
||||
open matcher would abandon the one guarantee this library exists to give —
|
||||
provable exhaustiveness — to automate what a `switch` and a default arm already
|
||||
cover. The type-level cost of supporting open universes is recorded in
|
||||
[development/library.md](./development/library.md#supported-universes).
|
||||
|
||||
## License
|
||||
|
||||
MIT © 2025 tmu. See [LICENSE](./LICENSE).
|
||||
MIT © 2026 tmu. See [LICENSE](./LICENSE).
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
+10
-7
@@ -8,13 +8,13 @@ Setup:
|
||||
☐ Split off template into separate package => pi --session 01a07dde-7050-7054-bb36-1606d7eb2bc3 @low
|
||||
|
||||
v1.0:
|
||||
☐ API surface is stable and fully typed
|
||||
☐ Finalize public exports in `src/index.ts`
|
||||
☐ Document all exported types and functions
|
||||
☐ Add JSDoc for public APIs
|
||||
☐ Test coverage meets threshold
|
||||
☐ Achieve 100% branch coverage on `src/primitive-union.ts`
|
||||
☐ Achieve 100% branch coverage on `src/index.ts`
|
||||
✔ API surface is stable and fully typed @done
|
||||
✔ Finalize public exports in `src/index.ts` @done
|
||||
✔ Document all exported types and functions @done
|
||||
✔ Add JSDoc for public APIs @done
|
||||
✔ Test coverage meets threshold @done
|
||||
✔ Achieve 100% branch coverage on `src/primitive-union.ts` @done
|
||||
✔ Achieve 100% branch coverage on `src/index.ts` @done
|
||||
|
||||
Matcher:
|
||||
✔ when using a union type as a property, the current behavior of tagged union matcher is @done
|
||||
@@ -28,6 +28,8 @@ Documentation:
|
||||
→ previous Examples order: literal/exhaustive, typeof, structural/discriminated unions, when, any
|
||||
☐ Create `examples/` directory with runnable snippets
|
||||
☐ Add comparison section vs. other TS pattern-matching libs in Readme.md
|
||||
☐ 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
|
||||
☐ Create backlog tasks for implementation
|
||||
☐ Validate code fences in Markdown (start with README.md) — compile the TypeScript examples against `src/` so the docs cannot drift from the API
|
||||
@@ -44,3 +46,4 @@ Maintenance:
|
||||
☐ Add a minimal dir-listing webserver to the gitea docker setup for serving landing page (reuse existing reverse proxy)
|
||||
☐ CI writes landing page to a shared volume keyed by project + tag (e.g. `/landing/tiny-pattern-ts/<tag>/`)
|
||||
☐ Browse to `…/tiny-pattern-ts/index.html` in the browser
|
||||
☐ Add testing with TypeScript 5.0 baseline in CI
|
||||
@@ -97,6 +97,39 @@ Leave `act_runner`'s `force_pull` disabled.
|
||||
(`docker rmi gitea.e1nsnull.de/tmu/act-ci:<version>`); do not reach for
|
||||
force-pull.
|
||||
|
||||
## Coverage threshold
|
||||
|
||||
#### Decision (2026-09)
|
||||
|
||||
`npm run test:ci` fails below 100% statements / branches / functions / lines
|
||||
across `src/**/*.ts` (`c8 --all --include "src/**/*.ts" --100`). The gate rides
|
||||
the `build` job; `npm run verify` stays coverage-free.
|
||||
|
||||
#### Why
|
||||
|
||||
- The types are the feature, so an untested branch is a hole in the contract,
|
||||
not a metric to trade off; 100% is the only threshold that means "no hole".
|
||||
- `--all` counts a `src/` file no test imports. Without it c8 reports only the
|
||||
files the suite happened to load, so a new untested module is invisible and
|
||||
the threshold passes vacuously.
|
||||
- The gate rides `test:ci`, which `build` already runs — no new job or step.
|
||||
- `verify` stays fast and local; the slower coverage run is a CI-only tier (see
|
||||
[workflow.md § Feedback tiers](./workflow.md#feedback-tiers)).
|
||||
|
||||
#### Rejected
|
||||
|
||||
- Per-file thresholds: a global 100% already forces every counted file to 100%.
|
||||
- A `check:coverage` script: it would re-run the suite or read c8's temp dir,
|
||||
and no `check:*` script runs tests.
|
||||
- `--all` without `--include`: it would also sweep `scripts/`, which is not the
|
||||
shipped surface.
|
||||
|
||||
#### Known issue
|
||||
|
||||
- `src/matcher-shared.ts` is types only, so its runtime image is empty; c8 still
|
||||
lists it under `--all`. It carries a file-level `/* c8 ignore start */` with
|
||||
the reason. Adding runtime code there means removing that directive.
|
||||
|
||||
## Coverage serving
|
||||
|
||||
#### Decision (2026-09)
|
||||
|
||||
+43
-7
@@ -5,8 +5,39 @@ user-facing reference is [README § API](../README.md#api).
|
||||
|
||||
The matchers are implemented in `src/primitive-union.ts` (`getPrimitiveUnionMatcher` /
|
||||
`getPrimitiveUnionMatcherW`) and `src/tagged-union.ts` (`getTaggedUnionMatcher` /
|
||||
`getTaggedUnionMatcherW`), re-exported from `src/index.ts`; the rest of the
|
||||
library is placeholder code.
|
||||
`getTaggedUnionMatcherW`), re-exported from `src/index.ts`; the rest of `src/`
|
||||
is implementation detail.
|
||||
|
||||
## Public surface
|
||||
|
||||
#### Decision (2026-09)
|
||||
|
||||
`src/index.ts` exports the four factories and nothing else. Every exported
|
||||
function carries TSDoc; the builder types and the `matcher-shared.ts`
|
||||
vocabulary stay internal.
|
||||
|
||||
#### Why
|
||||
|
||||
- The factories are the whole contract: a consumer calls one and never needs to
|
||||
name the builder type it returns.
|
||||
- The builder interfaces are already the inferred return types, so they travel
|
||||
into the emitted `.d.ts` regardless. Exporting them would only make them
|
||||
nameable while freezing the internal `Strict` / `Widening` overload split as
|
||||
API.
|
||||
- TSDoc travels into the emitted declarations, so editor hovers and the
|
||||
published package document the API without a hand-written `.d.ts`.
|
||||
|
||||
#### Rejected
|
||||
|
||||
- **Exporting the builder types** (`PrimitiveUnionMatcher`, …). Nameable, but it
|
||||
grows the surface for no call-site benefit and pins the `Strict` / `Widening`
|
||||
split.
|
||||
- **Exporting the `matcher-shared.ts` vocabulary** (`Matchable`, `UnaryFn`,
|
||||
`PatternKey`, `Member`, `PatternReturns`, …). They appear in the public
|
||||
signatures, but a consumer never needs to name them; exporting them would
|
||||
freeze plumbing as API.
|
||||
- **A hand-written `.d.ts` or a separate API document.** It would drift from the
|
||||
implementation; TSDoc is generated from the source.
|
||||
|
||||
## Matcher shape
|
||||
|
||||
@@ -167,7 +198,9 @@ Extract<T, Stringified<T>>` catches numeric collisions too.
|
||||
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 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>>>`):
|
||||
@@ -243,10 +276,13 @@ The key is a separate call because `K` is inferred from its literal argument and
|
||||
|
||||
#### Known issue
|
||||
|
||||
- A member's tag must be unique across the union; two members with the same tag
|
||||
collapse to a union under one handler. A tag colliding with its
|
||||
stringification (`true | "true"`) is rejected by the universe gate — see
|
||||
§ Supported universes.
|
||||
- A tag need not be unique across the union. Two members sharing one is not a
|
||||
soundness hole: they select a single runtime key, so one handler receiving
|
||||
their union is the only correct behavior — the key is simply not a
|
||||
discriminant. The gate rejects only the distinct-value collision
|
||||
(`true | "true"`), where two values share a key and `Member` can no longer
|
||||
invert it; see § Supported universes. Pinned by the duplicate-tag tests in
|
||||
`src/tagged-union.test.ts`.
|
||||
|
||||
## Primitive universe
|
||||
|
||||
|
||||
@@ -35,7 +35,8 @@ in
|
||||
[CONTRIBUTING.md § Development commands](../CONTRIBUTING.md#development-commands).
|
||||
`c8` uses V8 coverage, so the `--strip-types` source is instrumented without a
|
||||
build step, and the runner relies on the `.ts` import-extension convention (see
|
||||
[tooling.md](./tooling.md#source-imports-use-ts-extensions)).
|
||||
[tooling.md](./tooling.md#source-imports-use-ts-extensions)). CI gates that
|
||||
coverage at 100% (see [ci.md § Coverage threshold](./ci.md#coverage-threshold)).
|
||||
|
||||
## Handler arguments
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "tiny-pattern-ts",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "tiny-pattern-ts",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"type-fest": "^5.9.0"
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tiny-pattern-ts",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.1",
|
||||
"description": "Pattern matching for TypeScript/ESM environments (F#-style, not regex)",
|
||||
"keywords": [
|
||||
"adt",
|
||||
@@ -58,7 +58,7 @@
|
||||
"maintain:knip": "knip --include dependencies,exports,files",
|
||||
"maintain:outdated": "check-outdated --ignore-pre-releases --ignore-packages @types/node",
|
||||
"test": "npm run check:tsc && node --test --strip-types \"src/**/*.test.ts\"",
|
||||
"test:ci": "c8 --reporter=text --reporter=lcov --reporter=html node --test --strip-types \"src/**/*.test.ts\"",
|
||||
"test:ci": "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\"",
|
||||
"verify": "npm run check && npm run test:unit",
|
||||
"watch": "npm run watch:test",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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 {
|
||||
getPrimitiveUnionMatcher,
|
||||
getPrimitiveUnionMatcherW,
|
||||
|
||||
@@ -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";
|
||||
|
||||
// The primitive-union and tagged-union matchers differ in their universe, but the
|
||||
|
||||
@@ -96,9 +96,50 @@ const dispatch =
|
||||
shape as never,
|
||||
);
|
||||
|
||||
/**
|
||||
* Create a matcher for a finite primitive universe, with one common return
|
||||
* type.
|
||||
*
|
||||
* Use it when the value itself is the union (`"yes" | "no"`) and every handler
|
||||
* returns the same type.
|
||||
*
|
||||
* The returned builder takes a handler map keyed by the members; supplying a
|
||||
* second fallback argument allows a partial map and receives the unhandled
|
||||
* remainder.
|
||||
*
|
||||
* @typeParam T - The finite universe of primitive members to match.
|
||||
* @returns A builder for the handler map, or the handler map plus a fallback.
|
||||
* @example
|
||||
* const matchAnswer = getPrimitiveUnionMatcher<"yes" | "no">();
|
||||
* const describe = matchAnswer({
|
||||
* yes: () => "agreed",
|
||||
* no: () => "declined",
|
||||
* });
|
||||
* describe("yes"); // "agreed"
|
||||
*/
|
||||
export const getPrimitiveUnionMatcher = <
|
||||
T extends Matchable,
|
||||
>(): PrimitiveUnionMatcherStrict<T> => dispatch;
|
||||
|
||||
/**
|
||||
* Create a matcher for a finite primitive universe whose return type is the
|
||||
* union of every handler's return type.
|
||||
*
|
||||
* Use it when the value itself is the union (`"yes" | "no"`) and the handlers
|
||||
* return different types. The `W` (widening) counterpart of
|
||||
* {@link getPrimitiveUnionMatcher}; the universe constraint and the optional
|
||||
* fallback are identical.
|
||||
*
|
||||
* @typeParam T - The finite universe of primitive members to match.
|
||||
* @returns A builder for the handler map, or the handler map plus a fallback.
|
||||
* @example
|
||||
* const matchReply = getPrimitiveUnionMatcherW<"yes" | "no">();
|
||||
* const reply = matchReply({
|
||||
* yes: () => 1,
|
||||
* no: () => "declined",
|
||||
* });
|
||||
* // reply: (shape: "yes" | "no") => number | string
|
||||
*/
|
||||
export const getPrimitiveUnionMatcherW = <
|
||||
T extends Matchable,
|
||||
>(): PrimitiveUnionMatcherWidening<T> => dispatch;
|
||||
@@ -1244,6 +1244,82 @@ test("property union: a collision is rejected beside legal members", () => {
|
||||
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
|
||||
// ============================================================================
|
||||
|
||||
@@ -152,10 +152,56 @@ const dispatch =
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a matcher for a discriminated union, with one common return type.
|
||||
*
|
||||
* Use it when the value is an object discriminated by a property
|
||||
* (`{ kind: "circle" } | { kind: "square" }`) and every handler returns the
|
||||
* same type.
|
||||
*
|
||||
* The first call fixes the union `T`; the returned function takes the
|
||||
* discriminant property's name (`K`, restricted to properties whose values are
|
||||
* tags), and that returns the handler-map builder. Supplying a second fallback
|
||||
* argument to the builder allows a partial map and receives the members whose
|
||||
* tag was not handled. A `boolean`, `null` or `undefined` tag is keyed by its
|
||||
* stringified form (`true` -> `"true"`); see README § Caveats.
|
||||
*
|
||||
* @typeParam T - The discriminated-union type to match.
|
||||
* @returns A function that takes the discriminant property's name.
|
||||
* @example
|
||||
* type Shape =
|
||||
* | { kind: "circle"; radius: number }
|
||||
* | { kind: "square"; side: number };
|
||||
*
|
||||
* const matchShape = getTaggedUnionMatcher<Shape>()("kind");
|
||||
* const area = matchShape({
|
||||
* circle: (s) => Math.PI * s.radius ** 2,
|
||||
* square: (s) => s.side ** 2,
|
||||
* });
|
||||
*/
|
||||
export const getTaggedUnionMatcher = <
|
||||
T extends object,
|
||||
>(): TaggedUnionMatcherFactory<T> => dispatch;
|
||||
|
||||
/**
|
||||
* Create a matcher for a discriminated union whose return type is the union of
|
||||
* every handler's return type.
|
||||
*
|
||||
* Use it when the value is a discriminated object and the handlers return
|
||||
* different types. The `W` (widening) counterpart of
|
||||
* {@link getTaggedUnionMatcher}; the curried key step and the optional fallback
|
||||
* are identical.
|
||||
*
|
||||
* @typeParam T - The discriminated-union type to match.
|
||||
* @returns A function that takes the discriminant property's name.
|
||||
* @example
|
||||
* const matchShape = getTaggedUnionMatcherW<Shape>()("kind");
|
||||
* const describe = matchShape({
|
||||
* circle: () => "round",
|
||||
* square: () => 4,
|
||||
* });
|
||||
* // describe: (shape: Shape) => string | number
|
||||
*/
|
||||
export const getTaggedUnionMatcherW = <
|
||||
T extends object,
|
||||
>(): TaggedUnionMatcherWideningFactory<T> => dispatch;
|
||||
Reference in new issue
Block a user