35 Commits
Author SHA1 Message Date
tmu 1723419e69 🚀 Release 0.6.0
CI / release-gate (push) Successful in 3s
CI / build (push) Successful in 33s
CI / maintain (push) Failing after 13s
CI / publish (push) Failing after 16s
2026-09-22 11:10:34 +00:00
tmu dcd5f8d1c4 🔀 Merge feature/tagged-union-boolean-nullish-tags into main 2026-09-22 11:09:21 +00:00
tmu 4ebe18adb1 ♻️ Align primitive-union matcher names and drop stale lint disables
Rename `MatcherStrict` / `MatcherWidening` to
`PrimitiveUnionMatcherStrict` / `PrimitiveUnionMatcherWidening`, matching
the exported `getPrimitiveUnionMatcher(W)` names and the
`TaggedUnionMatcher*` pair.

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

Covers exhaustive and fallback dispatch, the redundant-fallback
guard, and the LSP popup offering the tags by name.
2026-09-22 10:22:59 +00:00
tmu b81a8149d9 🔀 Merge chore/rename-primitive-union into main 2026-09-22 09:58:28 +00:00
tmu a88d49ffe2 📝 Check off the primitive-union rename 2026-09-22 09:47:05 +00:00
tmu 02f736507c ♻️ Rename primitive to primitive-union
`getMatcher` / `getMatcherW` become `getPrimitiveUnionMatcher` /
`getPrimitiveUnionMatcherW`; `src/primitive.ts` and its test move to
`src/primitive-union.*`. The `Union` suffix mirrors `getTaggedUnionMatcher`.
2026-09-22 09:46:59 +00:00
tmu 0727117afb 🔀 Merge chore/lsp-symbol-search-rule into main 2026-09-22 09:43:56 +00:00
tmu b293e611b2 📝 Prefer LSP for symbol lookup in AGENTS 2026-09-22 09:43:08 +00:00
tmu 63b020338e 🔀 Merge chore/backlog-notes into main 2026-09-22 09:15:04 +00:00
tmu 32b9ce0987 📝 Note two matcher gaps in the backlog
Record the non-exhaustive broad-type hole in the primitive matcher and the
never-typed handler parameters the tagged-union matcher produces when a
property is a union, so both can be picked up as their own work units.
2026-09-22 09:14:58 +00:00
tmu 434c3a2222 🔀 Merge feature/tagged-union-matcher into main
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 27s
CI / publish (push) Skipped
CI / maintain (push) Failing after 15s
2026-09-21 23:29:48 +00:00
tmu 6280061737 📝 Note the primitive-union rename in the backlog 2026-09-21 23:24:06 +00:00
tmu 23a2776d55 📝 Note the widened-string-union matcher test 2026-09-21 23:21:19 +00:00
tmu 937d84ea48 ♻️ Share matcher internals 2026-09-21 23:15:09 +00:00
tmu 6f4f509b69 ⚡ Inline the tag read into dispatch 2026-09-21 23:02:56 +00:00
tmu 3f22e5ca01 🔥 Drop unused unified-signature disables 2026-09-21 22:53:42 +00:00
tmu 78e9af600d ✅ Cover the discriminant-key popup 2026-09-21 22:36:30 +00:00
tmu 8ab3c6dbc6 📝 Document the tagged-union matcher 2026-09-21 22:14:22 +00:00
tmu c0fc090ee4 ✨ Add a matcher for tagged unions 2026-09-21 22:14:10 +00:00
tmu a4b4618c9f 🚀 Release 0.5.0
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 30s
CI / maintain (push) Failing after 14s
CI / publish (push) Failing after 17s
2026-09-21 13:11:27 +00:00
tmu bf711cf9fc 🔀 Merge chore/test-handler-runtime-arguments into main 2026-09-21 13:09:10 +00:00
tmu 9e03c52473 ✅ Assert the shape each handler receives
A handler's `expectTypeOf(shape)` only proved the type the compiler inferred;
nothing observed the argument `dispatch` passed. Passing `String(shape)` at the
call site therefore kept the whole suite green while breaking every key whose
property name differs from its value (`true`, `null`, `1`).

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

Rule: CONTRIBUTING.md, rationale: development/testing.md § Handler arguments.
2026-09-21 12:52:58 +00:00
tmu aa6d71076c 🔀 Merge feature/boolean-null-undefined-patterns into main 2026-09-21 11:27:29 +00:00
tmu 80d498e4f4 ⚡ Index dispatch with the raw shape key
String(shape) defeats V8's numeric-key fast path (measured ~2x on
number-keyed dispatch). JS already coerces boolean/null/undefined to the
same property key, so assert shape to string | number and index directly.
The assertion is TS2538-only; the facade keeps it sound. Needs a source
no-unsafe-type-assertion disable, next to the existing one.
2026-09-21 11:23:14 +00:00
tmu 1ad19ba308 📝 Document matcher caveats in the README
State the end-user limits once, in README § Caveats: a member colliding
with its stringification, the unsupported symbol/bigint, and NaN/-0. Drop
the duplicated known-issue prose from development/library.md and the
source comment; link to the README instead.
2026-09-21 10:33:36 +00:00
tmu 16904440cf 📝 Place test-wide suppressions in the config
Drop the fixed no-floating-promises header exception from CONTRIBUTING.md
and AGENTS.md; agents must not add a source disable or edit .oxlintrc.json.
Record the split in tooling.md and testing.md: a one-site false positive is a
source disable, a file-class one lives in the test override.
2026-09-21 10:01:30 +00:00
tmu 9b7dec96e0 🔧 Move test lint disables to oxlint config
The no-floating-promises header was repeated at the top of every test
file. Centralize it, and the no-null suppression the new null/undefined
cases need, in the **/*.test.ts override.
2026-09-21 09:56:39 +00:00
tmu 45495fe2a8 ✨ Match boolean, null and undefined
Extend the matcher universe beyond string | number so a union can carry
boolean, null and undefined members. true|false, null and undefined are
not property keys, so handler keys are their stringification while the
callback still receives the real member. Symbols are rejected (a brand
is compile-time only) and bigint is not a property key.
2026-09-21 09:44:44 +00:00
18 changed files with 1431 additions and 135 deletions

No files matched your search

+3 -1
View File
@@ -34,7 +34,9 @@
"no-unused-expressions": "off",
"no-empty-file": "off",
"import/no-nodejs-modules": "off",
"eslint/no-magic-numbers": "off"
"eslint/no-magic-numbers": "off",
"unicorn/no-null": "off",
"typescript/no-floating-promises": "off"
}
},
{
+4 -3
View File
@@ -9,7 +9,7 @@ first-action facts. Do not restate evolving prose here — it will drift.
- Project: F#-style pattern matching for TypeScript/ESM. Node `>=26` (pinned via `.node-version`), ESM-only (no CommonJS shim).
- **While iterating:** `npm run test` (`check:tsc` + the unit suite) for fast feedback on the files you changed.
- **Optional code intelligence:** this repo installs `@spences10/pi-lsp` (pinned in `.pi/settings.json`) as a project-local pi extension. It talks to the repo's own TypeScript 7 via `tsc --lsp --stdio` and exposes **read-only** tools — `lsp_hover`, `lsp_definition`, `lsp_references`, `lsp_find_symbol`, `lsp_document_symbols`, `lsp_diagnostics(_many)`. Prefer `lsp_references` over `grep -w` for widely-colliding identifiers (`matches`, `type`, …); use `lsp_hover` to read inferred types on generic-heavy code. It has no rename / code-action / apply-edit surface — the write side is pi's `edit` tool + `check:tsc`. Treat empty LSP output as _inconclusive_, not success: **`npm run test` / `npm run verify` remain the sole authoritative gate** (see the next bullet). The server keeps running across that gate with a ~5 min idle timeout and registers no file watchers, so if you change `tsconfig.json` / `package.json` mid-session its diagnostics can be stale — when LSP output disagrees with `check:tsc`, trust `check:tsc` and restart pi (or wait out the idle timeout) before concluding the LSP is wrong.
- **Optional code intelligence:** this repo installs `@spences10/pi-lsp` (pinned in `.pi/settings.json`) as a project-local pi extension. It talks to the repo's own TypeScript 7 via `tsc --lsp --stdio` and exposes **read-only** tools — `lsp_hover`, `lsp_definition`, `lsp_references`, `lsp_find_symbol`, `lsp_document_symbols`, `lsp_diagnostics(_many)`. **When you are looking for a symbol, reach for the LSP before `rg`/`grep`** — `lsp_references` / `lsp_find_symbol` / `lsp_definition` / `lsp_document_symbols` are semantic and cross-file, so they see shadowing, imports and overloads that a text search cannot; use `lsp_hover` to read inferred types on generic-heavy code. Use `rg` for what the LSP cannot see — doc prose, string literals, config, task lists, file discovery — and reconcile the two sets before editing (symbols from the LSP, strings and prose from `rg`). It has no rename / code-action / apply-edit surface — the write side is pi's `edit` tool + `check:tsc`. Treat empty LSP output as _inconclusive_, not success: **`npm run test` / `npm run verify` remain the sole authoritative gate** (see the next bullet). The server keeps running across that gate with a ~5 min idle timeout and registers no file watchers, so if you change `tsconfig.json` / `package.json` mid-session its diagnostics can be stale — when LSP output disagrees with `check:tsc`, trust `check:tsc` and restart pi (or wait out the idle timeout) before concluding the LSP is wrong.
- **Definition of done — run this before you call the work finished:** `npm run verify`. If all green, commit. If red, look at the output, fix the root cause, and re-run.
- **On commit:** write a good message (see [CONTRIBUTING.md § Commit messages](./CONTRIBUTING.md#commit-messages)). Lefthook's pre-commit hook already runs the fast, offline, staged-file checks — don't run them by hand. If the hook fails on style, `npm run fix`, restage, recommit.
- **Document decisions where the next maintainer will look:** rationale, rejected alternatives and known issues go in `development/<category>.md` (see [development/README.md](./development/README.md)); the actionable rule stays in [CONTRIBUTING.md](./CONTRIBUTING.md) and links to it. Write each fact once — never copy the rule into `development/` or the reason into `CONTRIBUTING.md` — and change both in the same commit when a rule changes.
@@ -25,10 +25,11 @@ first-action facts. Do not restate evolving prose here — it will drift.
Don't silence the type system to force a green run. As an agent these are forbidden:
- `// @ts-nocheck`, `// @ts-ignore`, `// @ts-expect-error`
- `// oxlint-disable` / `// oxlint-disable-next-line` — the sole exception is the fixed file-level `typescript/no-floating-promises` header at the top of a `*.test.ts` file, spelled exactly as [CONTRIBUTING.md § Rules the tools don't enforce](./CONTRIBUTING.md#rules-the-tools-dont-enforce) prescribes
- `// oxlint-disable` / `// oxlint-disable-next-line`
- editing `.oxlintrc.json` to silence a finding (e.g. turning `typescript/no-floating-promises` off)
- `as` casts used to push an expression through (type-aware oxlint already flags unsafe assertions)
Fix the root cause with the type system instead — narrowing, generics, `satisfies`, conditional / mapped types, utility types (`NonNullable`, `Exclude`, …). TypeScript can express it; that's the intended tool. The `oxlint-disable`-location rule in [CONTRIBUTING.md § Rules the tools don't enforce](./CONTRIBUTING.md#rules-the-tools-dont-enforce) is a **human** last-resort convention (so a reviewer can spot a deliberate suppression) — it is not permission for you to add one, except the one fixed `*.test.ts` file-level header named there. If the types genuinely cannot express something, stop and surface the conflict (commit message / handover) rather than suppress it.
Fix the root cause with the type system instead — narrowing, generics, `satisfies`, conditional / mapped types, utility types (`NonNullable`, `Exclude`, …). TypeScript can express it; that's the intended tool. Suppressions — a source `oxlint-disable` **or** a `.oxlintrc.json` entry — are a **human** last resort, not a tool for you. If the types genuinely cannot express something, stop and surface the conflict (commit message / handover) rather than suppress it.
The same applies to the checks themselves: **never `git commit --no-verify`** (or otherwise skip a pre-commit / pre-push hook). The checks are fast and offline, so a redundant run is fine — bypassing a hook to get green is the identical anti-pattern. If a commit already skipped a hook, redo it through one: `git reset --soft HEAD~1 && git commit -C <skipped-sha>`.
+13 -1
View File
@@ -7,7 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.6.0] - 2026-09-22
- add `getTaggedUnionMatcher` / `getTaggedUnionMatcherW` for discriminated unions
- rename the primitive matcher to primitive-union: `getMatcher` /
`getMatcherW` → `getPrimitiveUnionMatcher` / `getPrimitiveUnionMatcherW`, and
`src/primitive.ts` / `src/primitive.test.ts` → `src/primitive-union.*`
## [0.5.0] - 2026-09-21
- reject a fallback when the handler map already covers the universe
- allow boolean, null and undefined in the matcher universe
## [0.4.0] - 2026-09-20
@@ -65,7 +75,9 @@ 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.4.0...main
[Unreleased]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.6.0...main
[0.6.0]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.5.0...0.6.0
[0.5.0]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.4.0...0.5.0
[0.4.0]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.3.0...0.4.0
[0.3.0]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.2.0...0.3.0
[0.2.0]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.8...0.2.0
+8 -8
View File
@@ -66,9 +66,12 @@ before the implementation. The loop is **type → red → green → refactor**:
4. **Refactor** — with the type system and the tests as the safety net, then
`npm run verify` as the definition-of-done gate.
Every test pairs an `expectTypeOf(...)` with an `assert.*`; keep them together.
Every test pairs an `expectTypeOf(...)` with an `assert.*`; keep them together
— including the expectations inside handler bodies, which pair with an
assertion on the value dispatch passed, not only the type it inferred (see
[development/testing.md § Handler arguments](./development/testing.md#handler-arguments)).
The autocomplete tests (`src/util/__tests__/lsp-completion.test.ts` for the
helper, `src/primitive.test.ts` for the matcher's popup) are the
helper, `src/primitive-union.test.ts` for the matcher's popup) are the
exception —
the language server, not the type system, is the oracle (see
[development/testing.md § Autocomplete](./development/testing.md#autocomplete)).
@@ -155,12 +158,9 @@ reaches for by default:
[Script prefix convention](#script-prefix-convention).
- **`oxlint-disable` directives live in source, not `.oxlintrc.json`.** The
trade-off must sit next to the code it silences. This is a _human_ last-resort
convention; agents must not add these — see
[AGENTS.md § Never do](./AGENTS.md#never-do). The sole agent exception is the
fixed file-level header at the top of a `*.test.ts` file, spelled exactly:
`/* oxlint-disable typescript/no-floating-promises -- expectTypeOf() is a sync
type-assertion library that the type-aware linter misidentifies as a promise
*/` — any other suppression stays human-last-resort. (why:
convention; agents must not add one, nor edit `.oxlintrc.json` to silence a
finding (e.g. `typescript/no-floating-promises`) — see
[AGENTS.md § Never do](./AGENTS.md#never-do). (why:
[development/tooling.md](./development/tooling.md#oxlint-disable-directives-live-next-to-the-code))
- **Don't put slow / network / whole-project scans in `check` or pre-commit.**
Advisory scans are not correctness gates; they belong under `maintain:`. (why:
+13 -1
View File
@@ -9,7 +9,7 @@ ordinary objects whose `matches` method is a TypeScript type guard, so narrowing
composes the way any other guard does. It is deliberately not a regex engine and
not a macro: there is no transpiler and no DSL to learn, and the type-level
contract is the feature — see [development/library.md](./development/library.md)
for the design decisions and the known limitations.
for the design decisions and [Caveats](#caveats) for the limits.
## Requirements
@@ -23,6 +23,18 @@ for the design decisions and the known limitations.
Yet to be implemented
## Caveats
- **A value and its stringification collide.** Object keys stringify, so a
universe that mixes a member with the string it stringifies to — `1 | "1"`,
`true | "true"`, `null | "null"` — collapses to a single handler key and both
members are routed to it. Use one form or the other.
- **`symbol` and `bigint` are not supported.** A `symbol` brand is a
compile-time phantom with nothing to match at runtime, and a `bigint` is not a
valid property key; neither satisfies the matcher's universe constraint.
- **`NaN` and `-0` cannot be matched specifically.** They have no literal type,
so both stay part of `number`.
## License
MIT © 2025 tmu. See [LICENSE](./LICENSE).
+24 -3
View File
@@ -24,7 +24,7 @@ v1.0:
☐ Document all exported types and functions
☐ Add JSDoc for public APIs
☐ Test coverage meets threshold
☐ Achieve 100% branch coverage on `src/primitive.ts`
☐ Achieve 100% branch coverage on `src/primitive-union.ts`
☐ Achieve 100% branch coverage on `src/index.ts`
Testing:
@@ -37,6 +37,12 @@ Testing:
✔ Rewrite `src/util/__tests__/lsp-completion.ts` onto `createMessageConnection` and typed requests @done
✔ Update `development/testing.md § Autocomplete` for the new client @done
✔ Run `npm run verify` and check the task off @done
☐ Test a literal union widened with `(string & {})` — `"red" | "green" | "yellow" | (string & {})` @medium
→ autocomplete should still offer the literals; arbitrary strings stay assignable and fall through to the fallback
☐ Type hole when using broad types like string as a universe @high
☐ handlers cannot be exhaustive, yet it is possible to not have a fallback
☐ one test even exploits this, I think for a negative test
☐ does the same type hole exist for tagged-union matchers or primitive-union matchers only?
Matcher:
✔ Clean up: adopt the 3-overload matcher (`src/prototype-ac2.ts`) and delete the prototypes @high @done
@@ -46,14 +52,29 @@ Matcher:
→ 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
→ 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
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
☐ Are there other primitive types that should be supported in union patterns? (e.g. `bigint`, `symbol`) @medium
✔ 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:
☐ Bring README.md back to its previous form — synopsis and examples restored, in the correct place
+112 -7
View File
@@ -3,9 +3,10 @@
The type-level design of the public API and the limitations it carries. The
user-facing reference is [README § API](../README.md#api).
The matcher is implemented in `src/primitive.ts` and re-exported from
`src/index.ts` as `getMatcher` / `getMatcherW`; the rest of the library is
placeholder code.
The matchers are implemented in `src/primitive-union.ts` (`getPrimitiveUnionMatcher` /
`getPrimitiveUnionMatcherW`) and `src/tagged-union.ts` (`getTaggedUnionMatcher` /
`getTaggedUnionMatcherW`), re-exported from `src/index.ts`; the rest of the
library is placeholder code.
## Matcher shape
@@ -15,8 +16,8 @@ A factory takes the universe and returns a builder; the builder takes a handler
map and an optional fallback:
```ts
const matcher = getMatcher<"a" | "b">()({ a: (s) => …, b: (s) => … });
const fallback = getMatcher<"a" | "b" | "c">()({ a: (s) => … }, (s) => …);
const matcher = getPrimitiveUnionMatcher<"a" | "b">()({ a: (s) => …, b: (s) => … });
const fallback = getPrimitiveUnionMatcher<"a" | "b" | "c">()({ a: (s) => … }, (s) => …);
```
Exhaustive or fallback is decided **at the call site**, by whether the second
@@ -24,8 +25,8 @@ argument is present. The fallback's parameter is the remainder
`Exclude<T, keyof Handled>`. Only the return-strictness axis remains, so there
are two factories:
- `getMatcher` — one common `R`; the fallback must fit it;
- `getMatcherW` — the union `PatternReturns<Handled> | R`.
- `getPrimitiveUnionMatcher` — one common `R`; the fallback must fit it;
- `getPrimitiveUnionMatcherW` — the union `PatternReturns<Handled> | R`.
Each factory is two overloads whose order is load-bearing:
@@ -91,3 +92,107 @@ Each factory is two overloads whose order is load-bearing:
- `Parameters<typeof factory>[0]` resolves only the **last** overload, so it is
not a sound "rejected" oracle for a factory. Factory-negative tests use
`@ts-expect-error` call sites (the test file only — the general ban stands).
## Shared internals
#### Decision (2026-09)
`src/matcher-shared.ts` holds the seven universe-agnostic pieces both matchers
use: `UnaryFn`, `PatternReturns`, `RedundantFallback`, `HandlerMap`, the shared
`Matchable` universe, and the `PatternKey` / `PatternParam` key projection.
#### Why
- `RedundantFallback`'s property name is the diagnostic, so one definition
keeps the two matchers' message from drifting; the other pieces appear
verbatim in both public signatures or are the same projection over each
matcher's universe.
- **`Matchable` is one definition, not two.** The primitive-union matcher's
universe and the tagged-union matcher's allowed `Tag` values are the same set,
so aliasing them keeps the two matchers from drifting apart on what they
accept (`symbol`/`bigint` rejected once).
#### Rejected
- **A generic `Matcher<Universe>` over the interface pair, `Handlers`,
`Fallback` and `MustBePartial`.** Each is built from its own universe
(`Tags`/`MapTaggedUnion` vs the primitive values); abstracting over the
F-bounded `Handled` constraint that makes the remainder work risks the
contextual typing it exists to preserve. `Matchable` and the `PatternKey` /
`PatternParam` projection are the pieces both universes genuinely share.
## Tagged-union matcher
#### Decision (2026-09)
`getTaggedUnionMatcher` / `getTaggedUnionMatcherW` mirror the primitive-union pair
with one extra curried step for the discriminant key:
```ts
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number };
const area = getTaggedUnionMatcher<Shape>()("kind")({
circle: (s) => Math.PI * s.radius ** 2,
square: (s) => s.side ** 2,
});
const fallback = getTaggedUnionMatcher<Shape>()("kind")(
{ circle: (s) => … },
(s) => …, // s: { kind: "square"; side: number }
);
```
The key is a separate call because `K` is inferred from its literal argument and
`T` is fixed by the first factory; one call could not infer both.
`Discriminated<T>` restricts the key to properties whose values are tags.
#### Why
- **Same fallback/remainder machinery as the primitive-union matcher.** `HandledMembers`
maps the handled tags to their members and `Exclude<T, …>` is the fallback's
parameter; the redundant-fallback guard is the same F-bounded constraint. Only
the "universe" changes — `T`'s members instead of primitive values.
- **`T extends object`, not `Record<PropertyKey, unknown>`.** An `interface` has
no implicit index signature, so the `Record` constraint would reject
interface-based unions. The runtime reads the tag off `object` with one
assertion, the tagged twin of the primitive-union dispatch's `shape as string | number`.
- **`MapTaggedUnion` distributes with `Extract`.** A duplicated tag yields a
union of members instead of dropping one.
- **A `boolean` / `null` / `undefined` tag goes through the shared
`PatternKey` / `PatternParam` projection.** `Discriminated` admits those tags
(they are in `Tag`), but they cannot key a mapped type, so the handler map is
keyed by the stringified form (`true` → `"true"`) and `PatternParam` inverts
it to recover the member. This is the same projection the primitive-union
matcher uses over its universe, which is why it lives in `matcher-shared.ts`.
#### Known issue
- A member's tag must be unique across the union; two members with the same tag
collapse to a union under one handler. The same holds for a tag colliding with
its stringification (`true | "true"`) — see [README § Caveats](../README.md#caveats).
## Primitive universe
#### Decision (2026-09)
The universe (`Matchable`) is `string | number | boolean | null | undefined`,
with `boolean` admitted as `true | false`.
`boolean`/`null`/`undefined` are not property keys, so handler-map keys are a
projection (`PatternKey`: each member stringified) and `PatternParam` inverts
it, so callbacks receive the real member (`true`, not `"true"`). The popup
offers `true`, `false`, `null`, `undefined` by name (verified over LSP). The
same projection is shared with the tagged-union matcher; see
§ Tagged-union matcher.
#### Why
- Runtime dispatch indexes with the raw `shape`; `handlers[true]` coerces to
`"true"` at runtime exactly as `String` would. The `shape as string | number`
assertion only placates `TS2538` and buys the number fast path (an explicit
`String()` defeats V8's numeric-key path: measured ~2× on number-keyed
dispatch).
- `symbol`/`bigint`/`NaN`/`-0` are rejected, and a member colliding with its
stringification is unguarded: user-facing, stated once in
[README § Caveats](../README.md#caveats).
+40 -6
View File
@@ -28,7 +28,7 @@ before the implementation.
- Runtime-first (classic red/green): it verifies the value, not the contract,
and the contract is the product.
- Testing the type only: it would not catch handler dispatch or the `_`
fallback (see `src/primitive.test.ts`).
fallback (see `src/primitive-union.test.ts`).
The runner is `node --test --strip-types "src/**/*.test.ts"` and the tiers are
in
@@ -37,6 +37,40 @@ in
build step, and the runner relies on the `.ts` import-extension convention (see
[tooling.md](./tooling.md#source-imports-use-ts-extensions)).
## Handler arguments
#### Decision (2026-09)
A handler's `expectTypeOf(shape)` is always paired with an assertion on the
argument `dispatch` actually passed: `assert.equal` where the handler runs for
one shape, `assert.ok(s === … || s === …)` over the set a `_` fallback accepts
(`assert` is imported as `strict`, so each comparison is `Object.is`). Where a
test should also prove that the _exact_ value reached the handler unchanged,
the fallback returns the shape verbatim and the call site asserts it.
#### Why
- The parameter's type is what the compiler inferred from the pattern; the
argument is what the runtime passed. Only the second can drift, and the keys
whose property name differs from their value (`true`, `null`, `1`) are
exactly where it can — see [library.md](./library.md).
- `String(shape)` at the call site keeps every type expectation and every
return-value assertion green; the argument assertions fail (10 of the 33
tests). Without them the suite never looks at the passed argument.
- Returning the shape verbatim costs a widening pattern nothing: the remainder
type joins the union of handler returns in place of a marker literal, so the
test still shows the widening it is named for.
#### Rejected
- A recorded `unknown[]` of every fallback call compared with `deepEqual`:
strong, but it couples the assertion to call order, and the sink sits three
blocks away from the value it observes.
- `typeof` checks: they cannot separate `2` from its key text `"2"`, which is
the drift a fallback with a numeric remainder can hit.
- One expected value asserted inline in a fallback: its argument is a _set_ of
shapes, so only the disjunction holds on every call.
## AAA ordering
The rule is in
@@ -133,7 +167,7 @@ The script prints the labels the server offers at a `/*COMPLETE*/` marker inside
`<file>` (the marker is stripped before the document is sent). Its `LspSession`
is imported by `src/util/__tests__/lsp-completion.test.ts` — which tests the
helper itself against inline documents, never the library's code — and by
`src/primitive.test.ts`, where the same probe asserts the matcher's popup;
`src/primitive-union.test.ts`, where the same probe asserts the matcher's popup;
the CLI is for manual inspection.
#### Why
@@ -167,8 +201,8 @@ the CLI is for manual inspection.
## Known issues
- The type-aware linter misidentifies `expectTypeOf()` as a floating promise, so
test files that use it (`src/primitive.test.ts`) carry a file-level
`oxlint-disable typescript/no-floating-promises` with an explanatory comment.
It is a known false positive, not a rule worth disabling project-wide (see
- The type-aware linter misidentifies `expectTypeOf()` as a floating promise.
It is a known false positive, so `typescript/no-floating-promises` is off for
`**/*.test.ts` in the `.oxlintrc.json` override rather than repeated as a
file-level header (see
[tooling.md § oxlint-disable directives live next to the code](./tooling.md#oxlint-disable-directives-live-next-to-the-code)).
+19 -11
View File
@@ -106,26 +106,34 @@ Type-aware oxlint is enabled via `options.typeAware: true` in `.oxlintrc.json`
#### Decision (2026-09)
A type-aware rule that false-positives is silenced with a source-level
`oxlint-disable` directive (see `src/primitive.ts`,
`src/primitive.test.ts`), not by turning the rule off in `.oxlintrc.json`.
A type-aware rule that false-positives **at one site** is silenced with a
source-level `oxlint-disable` directive (see `src/primitive-union.ts`). A rule that is
wrong for a whole **file class** is turned off in a `.oxlintrc.json` `overrides`
entry instead — e.g. `typescript/no-floating-promises` (synchronous
`expectTypeOf` reads as an unhandled promise) and `unicorn/no-null` (intentional
`null` inputs) for `**/*.test.ts`. The same exemption is not repeated as a
file-level header in every affected file.
#### Why
- The disable sits next to the code it silences, visible to anyone reading the
source.
- The rule stays on everywhere else, so only the mis-firing line is exempted.
- A one-site disable sits next to the code it silences, visible to anyone
reading the source, and the rule stays on everywhere else.
- A file-class rule is a property of the file class, not of one line; the
override states it once, where the rest of the file-class config lives.
#### Rejected
- A project-wide disable in `.oxlintrc.json` for a false positive: it hides the
exemption from the reader of the affected code and switches the rule off
repo-wide for a one-site problem.
- A project-wide disable in `.oxlintrc.json` for a one-site false positive: it
hides the exemption from the reader of the affected code and switches the rule
off repo-wide for a one-site problem.
- A repeated file-level `oxlint-disable` header for a file-class false positive:
the copies drift and scatter one config decision across the tree.
#### Known issue
- A source-level disable is a _human_ last resort. AI agents must not add one;
they fix the type at its root (see [AGENTS.md § Never do](../AGENTS.md#never-do)).
- Both placements are _human_ last resorts. AI agents must neither add a source
disable nor edit `.oxlintrc.json`; they fix the type at its root (see
[AGENTS.md § Never do](../AGENTS.md#never-do)).
### Unwanted stylistic rules are turned off in the config
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "tiny-pattern-ts",
"version": "0.4.0",
"version": "0.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tiny-pattern-ts",
"version": "0.4.0",
"version": "0.6.0",
"license": "MIT",
"dependencies": {
"type-fest": "^5.9.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tiny-pattern-ts",
"version": "0.4.0",
"version": "0.6.0",
"description": "Pattern matching for TypeScript/ESM environments (F#-style, not regex)",
"keywords": [
"adt",
+8 -1
View File
@@ -1 +1,8 @@
export { getMatcher, getMatcherW } from "./primitive.ts";
export {
getPrimitiveUnionMatcher,
getPrimitiveUnionMatcherW,
} from "./primitive-union.ts";
export {
getTaggedUnionMatcher,
getTaggedUnionMatcherW,
} from "./tagged-union.ts";
+60
View File
@@ -0,0 +1,60 @@
import type { ValueOf } from "type-fest";
// The primitive-union and tagged-union matchers differ in their universe, but the
// handler/fallback plumbing is identical; these are the shared pieces. The
// boundary is deliberate: `Handlers`, `Fallback` and `MustBePartial` stay with
// each matcher because they are built from its universe. See development/library.md.
// A handler: one universe member in, one return value out.
export type UnaryFn<T, R> = (shape: T) => R;
// The primitive universe a matcher can discriminate. The primitive-union matcher
// uses it directly; the tagged-union matcher uses it as the set of allowed
// discriminant (`Tag`) values. `boolean` is admitted as the pair `true | false`;
// see README § Caveats for the unsupported members.
export type Matchable = string | number | boolean | null | undefined;
// `boolean`, `null` and `undefined` cannot be property keys, so a mapped type
// over a universe that includes one keys each such member by its
// stringification. `PatternParam` inverts that projection, so a handler callback
// still receives the *real* member (`true`, not `"true"`). Both matchers use the
// projection: the primitive-union matcher over its universe, the tagged-union
// matcher over a discriminant property's values. See README § Caveats for the
// limits.
export type PatternKey<T> = T extends boolean
? T extends true
? "true"
: "false"
: T extends null
? "null"
: T extends undefined
? "undefined"
: T;
export type PatternParam<K> = K extends "true"
? true
: K extends "false"
? false
: K extends "null"
? null
: K extends "undefined"
? undefined
: K;
// `Extract` drops optional handlers (`undefined`) so `PatternReturns` also works
// when `P`'s constraint has optional keys.
export type PatternReturns<P> = ReturnType<
Extract<ValueOf<P>, (...args: never[]) => unknown>
>;
// The diagnostic raised when a fallback is supplied for an already-exhaustive
// handler map. Each matcher's `MustBePartial` folds it into `Handled`'s
// constraint so the guard is checked after inference.
export interface RedundantFallback {
readonly "every case is already handled, so the fallback is redundant": never;
}
// The runtime dispatch map the handler maps and fallback erase to.
export type HandlerMap = Record<
string | number,
UnaryFn<never, unknown> | undefined
>;
@@ -1,4 +1,3 @@
/* oxlint-disable typescript/no-floating-promises -- expectTypeOf() is a sync type-assertion library that the type-aware linter misidentifies as a promise */
import { strict as assert } from "node:assert";
import path from "node:path";
import { test } from "node:test";
@@ -10,24 +9,29 @@ import {
LspSession,
} from "#test-utils/lsp-completion.ts";
import { getMatcher, getMatcherW } from "./primitive.ts";
import {
getPrimitiveUnionMatcher,
getPrimitiveUnionMatcherW,
} from "./primitive-union.ts";
// ============================================================================
// API: getMatcher — ✔️ Exhaustive / ✔️ ReturnsStrict
// API: getPrimitiveUnionMatcher — ✔️ Exhaustive / ✔️ ReturnsStrict
// ============================================================================
test("getMatcher: exhaustive pattern infers one common return type", () => {
test("getPrimitiveUnionMatcher: exhaustive pattern infers one common return type", () => {
// Arrange
const factory = getMatcher<"a" | "b">();
const factory = getPrimitiveUnionMatcher<"a" | "b">();
// Act
const matcher = factory({
a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">();
assert.equal(s, "a");
return 1;
},
b: (s) => {
expectTypeOf(s).toEqualTypeOf<"b">();
assert.equal(s, "b");
return 2;
},
});
@@ -41,18 +45,21 @@ test("getMatcher: exhaustive pattern infers one common return type", () => {
assert.equal(matcher("b"), 2);
});
test("getMatcher: dispatches on numeric literal keys", () => {
test("getPrimitiveUnionMatcher: dispatches on numeric literal keys", () => {
// Arrange
const factory = getMatcher<1 | 2>();
const factory = getPrimitiveUnionMatcher<1 | 2>();
// Act
const matcher = factory({
1: (n) => {
expectTypeOf(n).toEqualTypeOf<1>();
// A numeric key must not reach its handler as the string `"1"`.
assert.equal(n, 1);
return n + 1;
},
2: (n) => {
expectTypeOf(n).toEqualTypeOf<2>();
assert.equal(n, 2);
return n * 10;
},
});
@@ -63,26 +70,30 @@ test("getMatcher: dispatches on numeric literal keys", () => {
assert.equal(matcher(2), 20);
});
test("getMatcher: dispatches on mixed string and numeric keys", () => {
test("getPrimitiveUnionMatcher: dispatches on mixed string and numeric keys", () => {
// Arrange
const factory = getMatcher<"a" | "b" | 1 | 2>();
const factory = getPrimitiveUnionMatcher<"a" | "b" | 1 | 2>();
// Act
const matcher = factory({
a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">();
assert.equal(s, "a");
return 1;
},
b: (s) => {
expectTypeOf(s).toEqualTypeOf<"b">();
assert.equal(s, "b");
return 2;
},
1: (n) => {
expectTypeOf(n).toEqualTypeOf<1>();
assert.equal(n, 1);
return 10;
},
2: (n) => {
expectTypeOf(n).toEqualTypeOf<2>();
assert.equal(n, 2);
return 20;
},
});
@@ -95,25 +106,77 @@ test("getMatcher: dispatches on mixed string and numeric keys", () => {
assert.equal(matcher(2), 20);
});
test("getPrimitiveUnionMatcher: dispatches on boolean literals", () => {
// Arrange
const factory = getPrimitiveUnionMatcher<boolean>();
// Act
const matcher = factory({
true: (s) => {
expectTypeOf(s).toEqualTypeOf<true>();
// The `true` key is a property name; the handler must still be
// called with the boolean `true`, not the string `"true"`.
assert.equal(s, true);
return 1;
},
false: (s) => {
expectTypeOf(s).toEqualTypeOf<false>();
assert.equal(s, false);
return 2;
},
});
// Assert
expectTypeOf(matcher).toEqualTypeOf<(shape: boolean) => number>();
assert.equal(matcher(true), 1);
assert.equal(matcher(false), 2);
});
test("getPrimitiveUnionMatcher: dispatches on null and undefined", () => {
// Arrange
const factory = getPrimitiveUnionMatcher<null | undefined>();
// Act
const matcher = factory({
null: (s) => {
expectTypeOf(s).toEqualTypeOf<null>();
assert.equal(s, null);
return "null";
},
undefined: (s) => {
expectTypeOf(s).toEqualTypeOf<undefined>();
assert.equal(s, undefined);
return "undefined";
},
});
// Assert
expectTypeOf(matcher).toEqualTypeOf<(shape: null | undefined) => string>();
assert.equal(matcher(null), "null");
assert.equal(matcher(undefined), "undefined");
});
// ============================================================================
// API: getMatcher — ❌ Exhaustive (fallback) / ✔️ ReturnsStrict
// API: getPrimitiveUnionMatcher — ❌ Exhaustive (fallback) / ✔️ ReturnsStrict
// ============================================================================
test("getMatcher: a `_` fallback receives the unhandled keys", () => {
test("getPrimitiveUnionMatcher: a `_` fallback receives the unhandled keys", () => {
// Arrange
const factory = getMatcher<"a" | "b" | "c">();
const factory = getPrimitiveUnionMatcher<"a" | "b" | "c">();
// Act
const matcher = factory(
{
a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">();
assert.equal(s, "a");
return 1 as const;
},
},
(s) => {
// The fallback sees only the keys `a` did not handle.
expectTypeOf(s).toEqualTypeOf<"b" | "c">();
assert.ok(s === "b" || s === "c");
return 2 as const;
},
);
@@ -127,24 +190,29 @@ test("getMatcher: a `_` fallback receives the unhandled keys", () => {
assert.equal(matcher("c"), 2);
});
test("getMatcher: a `_` fallback routes mixed string and numeric gaps", () => {
test("getPrimitiveUnionMatcher: a `_` fallback routes mixed string and numeric gaps", () => {
// Arrange
const factory = getMatcher<"a" | "b" | 1 | 2>();
const factory = getPrimitiveUnionMatcher<"a" | "b" | 1 | 2>();
// Act
const matcher = factory(
{
a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">();
assert.equal(s, "a");
return "A";
},
1: (n) => {
expectTypeOf(n).toEqualTypeOf<1>();
assert.equal(n, 1);
return "one";
},
},
(s) => {
expectTypeOf(s).toEqualTypeOf<"b" | 2>();
// The gap mixes a string and a number, so the number must reach the
// fallback as `2`, never as its key text `"2"`.
assert.ok(s === "b" || s === 2);
return "fallback";
},
);
@@ -157,22 +225,64 @@ test("getMatcher: a `_` fallback routes mixed string and numeric gaps", () => {
assert.equal(matcher(2), "fallback");
});
test("getPrimitiveUnionMatcher: a `_` fallback receives unhandled boolean and nullish keys", () => {
// Arrange
const factory = getPrimitiveUnionMatcher<
"a" | true | false | null | undefined
>();
// Act
const matcher = factory(
{
a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">();
assert.equal(s, "a");
return 1 as const;
},
null: (s) => {
expectTypeOf(s).toEqualTypeOf<null>();
assert.equal(s, null);
return 2 as const;
},
},
(s) => {
// `true` and `false` are keyed as `"true"`/`"false"` but the
// fallback still sees them as booleans.
expectTypeOf(s).toEqualTypeOf<true | false | undefined>();
assert.ok(s === true || s === false || s === undefined);
return 3 as const;
},
);
// Assert
expectTypeOf(matcher).toEqualTypeOf<
(shape: "a" | true | false | null | undefined) => 1 | 2 | 3
>();
assert.equal(matcher("a"), 1);
assert.equal(matcher(true), 3);
assert.equal(matcher(false), 3);
assert.equal(matcher(null), 2);
assert.equal(matcher(undefined), 3);
});
// ============================================================================
// API: getMatcherW — ✔️ Exhaustive / ❌ ReturnsStrict
// API: getPrimitiveUnionMatcherW — ✔️ Exhaustive / ❌ ReturnsStrict
// ============================================================================
test("getMatcherW: exhaustive pattern widens to the union of handler returns", () => {
test("getPrimitiveUnionMatcherW: exhaustive pattern widens to the union of handler returns", () => {
// Arrange
const factory = getMatcherW<"x" | "y">();
const factory = getPrimitiveUnionMatcherW<"x" | "y">();
// Act
const matcher = factory({
x: (s) => {
expectTypeOf(s).toEqualTypeOf<"x">();
assert.equal(s, "x");
return 1 as const;
},
y: (s) => {
expectTypeOf(s).toEqualTypeOf<"y">();
assert.equal(s, "y");
return "two" as const;
},
});
@@ -186,26 +296,30 @@ test("getMatcherW: exhaustive pattern widens to the union of handler returns", (
assert.equal(matcher("y"), "two");
});
test("getMatcherW: dispatches on mixed string and numeric keys", () => {
test("getPrimitiveUnionMatcherW: dispatches on mixed string and numeric keys", () => {
// Arrange
const factory = getMatcherW<"a" | "b" | 1 | 2>();
const factory = getPrimitiveUnionMatcherW<"a" | "b" | 1 | 2>();
// Act
const matcher = factory({
a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">();
assert.equal(s, "a");
return "A" as const;
},
b: (s) => {
expectTypeOf(s).toEqualTypeOf<"b">();
assert.equal(s, "b");
return "B" as const;
},
1: (n) => {
expectTypeOf(n).toEqualTypeOf<1>();
assert.equal(n, 1);
return 10 as const;
},
2: (n) => {
expectTypeOf(n).toEqualTypeOf<2>();
assert.equal(n, 2);
return 20 as const;
},
});
@@ -220,80 +334,127 @@ test("getMatcherW: dispatches on mixed string and numeric keys", () => {
assert.equal(matcher(2), 20);
});
test("getPrimitiveUnionMatcherW: exhaustive boolean and nullish widen to the union", () => {
// Arrange
const factory = getPrimitiveUnionMatcherW<boolean | null | undefined>();
// Act
const matcher = factory({
true: (s) => {
expectTypeOf(s).toEqualTypeOf<true>();
assert.equal(s, true);
return "yes" as const;
},
false: (s) => {
expectTypeOf(s).toEqualTypeOf<false>();
assert.equal(s, false);
return "no" as const;
},
null: (s) => {
expectTypeOf(s).toEqualTypeOf<null>();
assert.equal(s, null);
return 0 as const;
},
undefined: (s) => {
expectTypeOf(s).toEqualTypeOf<undefined>();
assert.equal(s, undefined);
return 1 as const;
},
});
// Assert
expectTypeOf(matcher).toEqualTypeOf<
(shape: boolean | null | undefined) => "yes" | "no" | 0 | 1
>();
assert.equal(matcher(true), "yes");
assert.equal(matcher(false), "no");
assert.equal(matcher(null), 0);
assert.equal(matcher(undefined), 1);
});
// ============================================================================
// API: getMatcherW — ❌ Exhaustive (fallback) / ❌ ReturnsStrict
// API: getPrimitiveUnionMatcherW — ❌ Exhaustive (fallback) / ❌ ReturnsStrict
// ============================================================================
test("getMatcherW: a `_` fallback widens gaps into the union", () => {
test("getPrimitiveUnionMatcherW: a `_` fallback widens gaps into the union", () => {
// Arrange
const factory = getMatcherW<"x" | "y" | "z">();
const factory = getPrimitiveUnionMatcherW<"x" | "y" | "z">();
// Act
const matcher = factory(
{
x: (s) => {
expectTypeOf(s).toEqualTypeOf<"x">();
assert.equal(s, "x");
return 1 as const;
},
},
(s) => {
// The fallback sees only the keys `x` did not handle.
expectTypeOf(s).toEqualTypeOf<"y" | "z">();
return "fallback" as const;
// Returning the shape verbatim lets the `Assert` block check the
// exact value dispatch passed.
return s;
},
);
// Assert
expectTypeOf(matcher).toEqualTypeOf<
(shape: "x" | "y" | "z") => 1 | "fallback"
(shape: "x" | "y" | "z") => 1 | "y" | "z"
>();
// ❌ a value outside T must not be accepted by the matcher
expectTypeOf<"w">().not.toExtend<Parameters<typeof matcher>[0]>();
assert.equal(matcher("x"), 1);
assert.equal(matcher("y"), "fallback");
assert.equal(matcher("z"), "fallback");
assert.equal(matcher("y"), "y");
assert.equal(matcher("z"), "z");
});
test("getMatcherW: a `_` fallback widens mixed string and numeric returns", () => {
test("getPrimitiveUnionMatcherW: a `_` fallback widens mixed string and numeric returns", () => {
// Arrange
const factory = getMatcherW<"a" | "b" | 1 | 2>();
const factory = getPrimitiveUnionMatcherW<"a" | "b" | 1 | 2>();
// Act
const matcher = factory(
{
a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">();
assert.equal(s, "a");
return "A" as const;
},
1: (n) => {
expectTypeOf(n).toEqualTypeOf<1>();
assert.equal(n, 1);
return 10 as const;
},
},
(s) => {
expectTypeOf(s).toEqualTypeOf<"b" | 2>();
return "fallback" as const;
// The number must arrive as `2`, not as its key text `"2"`.
assert.ok(s === "b" || s === 2);
// Returning the shape verbatim lets the `Assert` block check the
// exact value dispatch passed.
return s;
},
);
// Assert
// ❌ ReturnsStrict: mixed handler returns widen to their union.
expectTypeOf(matcher).toEqualTypeOf<
(shape: "a" | "b" | 1 | 2) => "A" | 10 | "fallback"
(shape: "a" | "b" | 1 | 2) => "A" | 10 | "b" | 2
>();
assert.equal(matcher("a"), "A");
assert.equal(matcher("b"), "fallback");
assert.equal(matcher("b"), "b");
assert.equal(matcher(1), 10);
assert.equal(matcher(2), "fallback");
assert.equal(matcher(2), 2);
});
// ============================================================================
// Factory contracts — calls that must not compile
// ============================================================================
test("getMatcher factory rejects patterns outside its contract", () => {
test("getPrimitiveUnionMatcher factory rejects patterns outside its contract", () => {
// Arrange
const factory = getMatcher<"a" | "b">();
const factory = getPrimitiveUnionMatcher<"a" | "b">();
// Act / Assert — the calls below must not compile.
// `Parameters<typeof factory>[0]` resolves only the *last* overload, so it
@@ -313,15 +474,40 @@ test("getMatcher factory rejects patterns outside its contract", () => {
factory({ a: () => 1, b: () => 2 }, () => 0);
});
test("getMatcher: an open universe keeps the fallback's remainder open", () => {
test("getPrimitiveUnionMatcher factory rejects non-exhaustive boolean and nullish maps", () => {
// Arrange
const factory = getMatcher<string>();
const factory = getPrimitiveUnionMatcher<boolean | null | undefined>();
// Act / Assert — the calls below must not compile
// @ts-expect-error only `true` is handled; `false`, `null`, `undefined` are not
factory({ true: () => 1 });
// @ts-expect-error `null` and `undefined` are not handled
factory({ true: () => 1, false: () => 2 });
factory(
// @ts-expect-error a fallback is redundant once the map covers the universe
{
true: () => 1,
false: () => 2,
null: () => 3,
undefined: () => 4,
},
() => 0,
);
});
test("getPrimitiveUnionMatcher: an open universe keeps the fallback's remainder open", () => {
// Arrange
const factory = getPrimitiveUnionMatcher<string>();
// Act
const matcher = factory({ a: () => 1 }, (s) => {
// The map's literal keys do not close an open universe, so the
// remainder stays `string` and the fallback is not redundant.
expectTypeOf(s).toEqualTypeOf<string>();
// A strict pattern fixes one `R` for every handler, so this fallback
// cannot return its shape; `typeof` is the strongest claim the value
// makes on its own — any string passes, including `""`.
assert.equal(typeof s, "string");
return 2 as const;
});
@@ -331,9 +517,9 @@ test("getMatcher: an open universe keeps the fallback's remainder open", () => {
assert.equal(matcher("b"), 2);
});
test("getMatcherW factory rejects patterns outside its contract", () => {
test("getPrimitiveUnionMatcherW factory rejects patterns outside its contract", () => {
// Arrange
const factory = getMatcherW<"x" | "y">();
const factory = getPrimitiveUnionMatcherW<"x" | "y">();
// Act / Assert — the calls below must not compile
factory({
@@ -352,17 +538,41 @@ test("getMatcherW factory rejects patterns outside its contract", () => {
// Dispatch — runtime behavior
// ============================================================================
test("getMatcher: an unhandled shape throws without a fallback", () => {
test("getPrimitiveUnionMatcher: an unhandled shape throws without a fallback", () => {
// Arrange — an open universe types its handler map as an index signature,
// so the type system cannot prove the runtime map is exhaustive.
const handlers: Record<string, () => number> = { a: () => 1 };
const matcher = getMatcher<string>()(handlers);
const handlers: Record<string, (shape: unknown) => number> = {
a: (shape) => {
// The raw shape reaches the handler, not the property key's text.
assert.equal(shape, "a");
return 1;
},
};
const matcher = getPrimitiveUnionMatcher<string>()(handlers);
// Act / Assert
assert.equal(matcher("a"), 1);
assert.throws(() => matcher("b"), /Unhandled shape: b/);
});
test("getPrimitiveUnionMatcher: an unhandled boolean shape throws without a fallback", () => {
// Arrange — an `string | boolean` universe widens its handler map to an
// index signature, so the runtime map's exhaustiveness is not provable.
const handlers: Record<string, (shape: unknown) => number> = {
true: (shape) => {
// `true` indexes the map as the property `"true"`, but the handler
// is still called with the boolean.
assert.equal(shape, true);
return 1;
},
};
const matcher = getPrimitiveUnionMatcher<string | boolean>()(handlers);
// Act / Assert
assert.equal(matcher(true), 1);
assert.throws(() => matcher(false), /Unhandled shape: false/);
});
// ============================================================================
// Autocomplete — the language server is the oracle, not the type system
// ============================================================================
@@ -379,8 +589,9 @@ const UNIVERSE = `"a" | "b" | "c"`;
// documents, project membership), so they pass in any order.
interface LabelsProbe {
readonly name: string;
readonly factory: "getMatcher" | "getMatcherW";
readonly factory: "getPrimitiveUnionMatcher" | "getPrimitiveUnionMatcherW";
readonly body: string;
readonly universe?: string;
readonly tail?: string;
}
@@ -388,6 +599,7 @@ const labelsFor = ({
name,
factory,
body,
universe = UNIVERSE,
tail = "",
}: LabelsProbe): Promise<readonly string[]> => {
const session = new LspSession(REPO_ROOT);
@@ -395,7 +607,7 @@ const labelsFor = ({
file: `src/__autocomplete_${name}.ts`,
source: [
`import { ${factory} } from "./index.ts";`,
`const m = ${factory}<${UNIVERSE}>()({`,
`const m = ${factory}<${universe}>()({`,
body,
`}${tail});`,
"",
@@ -409,12 +621,12 @@ const labelsFor = ({
test("autocomplete: an exhaustive pattern requires the universe", () => {
// Arrange
const name = "getMatcher_fresh";
const name = "getPrimitiveUnionMatcher_fresh";
// Act
const labels = labelsFor({
name,
factory: "getMatcher",
factory: "getPrimitiveUnionMatcher",
body: " /*COMPLETE*/",
});
@@ -426,12 +638,12 @@ test("autocomplete: an exhaustive pattern requires the universe", () => {
test("autocomplete: handled keys drop out of the popup", () => {
// Arrange
const name = "getMatcher_after_key";
const name = "getPrimitiveUnionMatcher_after_key";
// Act
const labels = labelsFor({
name,
factory: "getMatcher",
factory: "getPrimitiveUnionMatcher",
body: " a: () => 1,\n /*COMPLETE*/",
});
@@ -443,12 +655,12 @@ test("autocomplete: handled keys drop out of the popup", () => {
test("autocomplete: a fallback makes the remaining keys optional", () => {
// Arrange
const name = "getMatcher_with_fallback";
const name = "getPrimitiveUnionMatcher_with_fallback";
// Act
const labels = labelsFor({
name,
factory: "getMatcher",
factory: "getPrimitiveUnionMatcher",
body: " /*COMPLETE*/",
tail: ", () => 0",
});
@@ -461,12 +673,12 @@ test("autocomplete: a fallback makes the remaining keys optional", () => {
test("autocomplete: with a fallback, handled keys stay optional", () => {
// Arrange
const name = "getMatcher_with_fallback_after_key";
const name = "getPrimitiveUnionMatcher_with_fallback_after_key";
// Act
const labels = labelsFor({
name,
factory: "getMatcher",
factory: "getPrimitiveUnionMatcher",
body: " a: () => 1,\n /*COMPLETE*/",
tail: ", () => 0",
});
@@ -477,14 +689,14 @@ test("autocomplete: with a fallback, handled keys stay optional", () => {
});
});
test("autocomplete: `getMatcherW` offers the same popup as `getMatcher`", () => {
test("autocomplete: `getPrimitiveUnionMatcherW` offers the same popup as `getPrimitiveUnionMatcher`", () => {
// Arrange
const name = "getMatcherW_fresh";
const name = "getPrimitiveUnionMatcherW_fresh";
// Act
const labels = labelsFor({
name,
factory: "getMatcherW",
factory: "getPrimitiveUnionMatcherW",
body: " /*COMPLETE*/",
});
@@ -494,14 +706,14 @@ test("autocomplete: `getMatcherW` offers the same popup as `getMatcher`", () =>
});
});
test("autocomplete: `getMatcherW` also offers optional keys with a fallback", () => {
test("autocomplete: `getPrimitiveUnionMatcherW` also offers optional keys with a fallback", () => {
// Arrange
const name = "getMatcherW_with_fallback";
const name = "getPrimitiveUnionMatcherW_with_fallback";
// Act
const labels = labelsFor({
name,
factory: "getMatcherW",
factory: "getPrimitiveUnionMatcherW",
body: " /*COMPLETE*/",
tail: ", () => 0",
});
@@ -511,3 +723,39 @@ test("autocomplete: `getMatcherW` also offers optional keys with a fallback", ()
assert.deepEqual([...result], ["a?", "b?", "c?"]);
});
});
test("autocomplete: boolean and nullish keys are offered by name", () => {
// Arrange
const name = "getPrimitiveUnionMatcher_boolean_nullish";
// Act
const labels = labelsFor({
name,
factory: "getPrimitiveUnionMatcher",
universe: "boolean | null | undefined",
body: " /*COMPLETE*/",
});
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["false", "null", "true", "undefined"]);
});
});
test("autocomplete: a handled boolean literal drops out of the popup", () => {
// Arrange
const name = "getPrimitiveUnionMatcher_boolean_after_key";
// Act
const labels = labelsFor({
name,
factory: "getPrimitiveUnionMatcher",
universe: "boolean | null | undefined",
body: " true: () => 1,\n /*COMPLETE*/",
});
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["false", "null", "undefined"]);
});
});
+37 -32
View File
@@ -1,14 +1,18 @@
import type { Exact, ValueOf } from "type-fest";
import type { Exact } from "type-fest";
type UnaryFn<T, R> = (shape: T) => R;
import type {
HandlerMap,
Matchable,
PatternKey,
PatternParam,
PatternReturns,
RedundantFallback,
UnaryFn,
} from "./matcher-shared.ts";
// `Extract` drops optional handlers (`undefined`) so `PatternReturns` also works
// when `P`'s constraint has optional keys.
type PatternReturns<P> = ReturnType<
Extract<ValueOf<P>, (...args: never[]) => unknown>
>;
type Handlers<T extends string | number, R> = { [K in T]: UnaryFn<K, R> };
type Handlers<T extends Matchable, R> = {
[K in PatternKey<T>]: UnaryFn<PatternParam<K>, R>;
};
// The fallback is a *second argument*, not a property of the handler map,
// because its parameter is the remainder `Exclude<T, keyof Handled>` and TypeScript
@@ -16,8 +20,8 @@ type Handlers<T extends string | number, R> = { [K in T]: UnaryFn<K, R> };
// argument, by contrast, is contextually typed from inference on an earlier
// one, so the split is what makes the remainder expressible at all.
// See development/library.md.
type Fallback<T extends string | number, Handled, R> = UnaryFn<
Exclude<T, keyof Handled>,
type Fallback<T extends Matchable, Handled, R> = UnaryFn<
Exclude<T, PatternParam<keyof Handled>>,
R
>;
@@ -29,22 +33,17 @@ type Fallback<T extends string | number, Handled, R> = UnaryFn<
// failure is reported on the argument that inferred `Handled` (the handler
// map), so the required property is spelled as the message instead of relying
// on its position. See development/library.md.
interface RedundantFallback {
readonly "every case is already handled, so the fallback is redundant": never;
}
type MustBePartial<T extends string | number, Handled> = T extends keyof Handled
? RedundantFallback
: unknown;
type MustBePartial<T extends Matchable, Handled> =
PatternKey<T> extends keyof Handled ? RedundantFallback : unknown;
// TypeScript does not apply the excess-property check to a generic constraint,
// so `Exact` restores it for the generic forms: a handler map can otherwise
// carry keys outside `T`.
// oxlint-disable typescript/unified-signatures
// Strict returns: one common `R`. Overload order is load-bearing:
// #1 Handlers (first) -> the exhaustive form and the autocomplete popup
// #2 Fallback (last) -> accepts a partial handler map plus a fallback
interface MatcherStrict<T extends string | number> {
interface PrimitiveUnionMatcherStrict<T extends Matchable> {
<R>(handlers: Handlers<T, R>): UnaryFn<T, R>;
<
R,
@@ -55,13 +54,11 @@ interface MatcherStrict<T extends string | number> {
fallback: Fallback<T, Handled, R>,
): UnaryFn<T, R>;
}
// oxlint-enable typescript/unified-signatures
// Widened returns: the union of every handler's return type. `P` is inferred
// from the whole handler map, whose closed constraint supplies the
// contextual/autocomplete type.
// oxlint-disable typescript/unified-signatures
interface MatcherWidening<T extends string | number> {
interface PrimitiveUnionMatcherWidening<T extends Matchable> {
<P extends Exact<Handlers<T, unknown>, P>>(
handlers: P,
): UnaryFn<T, PatternReturns<P>>;
@@ -74,25 +71,33 @@ interface MatcherWidening<T extends string | number> {
fallback: Fallback<T, Handled, R>,
): UnaryFn<T, PatternReturns<Handled> | R>;
}
// oxlint-enable typescript/unified-signatures
type HandlerMap = Record<string | number, UnaryFn<never, unknown> | undefined>;
const dispatch =
(handlers: HandlerMap, fallback?: UnaryFn<never, unknown>) =>
(shape: string | number): unknown =>
(shape: Matchable): unknown =>
// `handlers[true]` already coerces to the `"true"` property at runtime,
// identical to `handlers[String(shape)]`, so indexing with `shape`
// directly is sound: `shape` is a facade-checked universe member and
// `PatternKey` only ever produces valid property keys. The assertion is
// needed solely because TypeScript forbids indexing with
// `boolean`/`null`/`undefined` (TS2538); it buys the number fast path.
(
handlers[shape] ??
handlers[
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
shape as string | number
] ??
fallback ??
(() => {
throw new Error(`Unhandled shape: ${shape}`);
throw new Error(`Unhandled shape: ${String(shape)}`);
})
)(
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
shape as never,
);
export const getMatcher = <T extends string | number>(): MatcherStrict<T> =>
dispatch;
export const getMatcherW = <T extends string | number>(): MatcherWidening<T> =>
dispatch;
export const getPrimitiveUnionMatcher = <
T extends Matchable,
>(): PrimitiveUnionMatcherStrict<T> => dispatch;
export const getPrimitiveUnionMatcherW = <
T extends Matchable,
>(): PrimitiveUnionMatcherWidening<T> => dispatch;
+640
View File
@@ -0,0 +1,640 @@
import { strict as assert } from "node:assert";
import path from "node:path";
import { test } from "node:test";
import { expectTypeOf } from "expect-type";
import {
type CompletionTarget,
LspSession,
} from "#test-utils/lsp-completion.ts";
import {
getTaggedUnionMatcher,
getTaggedUnionMatcherW,
} from "./tagged-union.ts";
interface Circle {
readonly kind: "circle";
readonly radius: number;
}
interface Square {
readonly kind: "square";
readonly side: number;
}
interface Triangle {
readonly kind: "triangle";
readonly base: number;
readonly height: number;
}
type Shape = Circle | Square | Triangle;
// ============================================================================
// API: getTaggedUnionMatcher — ✔️ Exhaustive / ✔️ ReturnsStrict
// ============================================================================
test("getTaggedUnionMatcher: exhaustive pattern infers one common return type", () => {
// Arrange
const factory = getTaggedUnionMatcher<Shape>()("kind");
// Act
const area = factory({
circle: (s) => {
// Each handler receives the member its tag selects, not the union.
expectTypeOf(s).toEqualTypeOf<Circle>();
assert.equal(s.kind, "circle");
return Math.PI * s.radius ** 2;
},
square: (s) => {
expectTypeOf(s).toEqualTypeOf<Square>();
assert.equal(s.kind, "square");
return s.side ** 2;
},
triangle: (s) => {
expectTypeOf(s).toEqualTypeOf<Triangle>();
assert.equal(s.kind, "triangle");
return (s.base * s.height) / 2;
},
});
// Assert
// ✔️ ReturnsStrict: R is the best common return type, not a widening union.
expectTypeOf(area).toEqualTypeOf<(shape: Shape) => number>();
assert.equal(area({ kind: "square", side: 2 }), 4);
assert.equal(area({ kind: "triangle", base: 2, height: 3 }), 3);
});
test("getTaggedUnionMatcher: dispatches on numeric tags", () => {
// Arrange
type Version =
| { readonly kind: 1; readonly a: number }
| {
readonly kind: 2;
readonly b: number;
};
const factory = getTaggedUnionMatcher<Version>()("kind");
// Act
const pick = factory({
1: (v) => {
expectTypeOf(v).toEqualTypeOf<{
readonly kind: 1;
readonly a: number;
}>();
assert.equal(v.kind, 1);
return v.a;
},
2: (v) => {
expectTypeOf(v).toEqualTypeOf<{
readonly kind: 2;
readonly b: number;
}>();
assert.equal(v.kind, 2);
return v.b;
},
});
// Assert
expectTypeOf(pick).toEqualTypeOf<(shape: Version) => number>();
assert.equal(pick({ kind: 1, a: 10 }), 10);
assert.equal(pick({ kind: 2, b: 20 }), 20);
});
test("getTaggedUnionMatcher: dispatches on boolean tags", () => {
// Arrange — a boolean discriminant is not a property key, so the handler
// map is keyed `true`/`false` and the callback still receives the boolean.
interface Ok {
readonly status: true;
readonly value: number;
}
interface Err {
readonly status: false;
readonly message: string;
}
type Result = Ok | Err;
const factory = getTaggedUnionMatcher<Result>()("status");
// Act
const pick = factory({
true: (s) => {
expectTypeOf(s).toEqualTypeOf<Ok>();
assert.equal(s.status, true);
return s.value;
},
false: (s) => {
expectTypeOf(s).toEqualTypeOf<Err>();
assert.equal(s.status, false);
return s.message.length;
},
});
// Assert
expectTypeOf(pick).toEqualTypeOf<(shape: Result) => number>();
assert.equal(pick({ status: true, value: 3 }), 3);
assert.equal(pick({ status: false, message: "hi" }), 2);
});
test("getTaggedUnionMatcher: dispatches on null and undefined tags", () => {
// Arrange
type Maybe =
| { readonly kind: null; readonly text: string }
| { readonly kind: undefined; readonly code: number };
const factory = getTaggedUnionMatcher<Maybe>()("kind");
// Act
const pick = factory({
null: (s) => {
expectTypeOf(s).toEqualTypeOf<{
readonly kind: null;
readonly text: string;
}>();
assert.equal(s.kind, null);
return s.text.length;
},
undefined: (s) => {
expectTypeOf(s).toEqualTypeOf<{
readonly kind: undefined;
readonly code: number;
}>();
assert.equal(s.kind, undefined);
return s.code;
},
});
// Assert
expectTypeOf(pick).toEqualTypeOf<(shape: Maybe) => number>();
assert.equal(pick({ kind: null, text: "hi" }), 2);
assert.equal(pick({ kind: undefined, code: 7 }), 7);
});
test("getTaggedUnionMatcher: a fallback receives unhandled boolean and nullish members", () => {
// Arrange
type Mixed =
| { readonly kind: "a"; readonly a: number }
| { readonly kind: true; readonly b: number }
| { readonly kind: false; readonly c: number }
| { readonly kind: null; readonly d: number };
const factory = getTaggedUnionMatcher<Mixed>()("kind");
// Act
const pick = factory({ a: () => 1 as const }, (s) => {
// The fallback sees the real members, not the projected keys.
expectTypeOf(s).toEqualTypeOf<
| { readonly kind: true; readonly b: number }
| { readonly kind: false; readonly c: number }
| { readonly kind: null; readonly d: number }
>();
// `true`/`false`/`null` are keyed as `"true"`/`"false"`/`"null"`,
// but the fallback still receives the boolean/null, not the string.
assert.ok(s.kind === true || s.kind === false || s.kind === null);
return 2 as const;
});
// Assert
expectTypeOf(pick).toEqualTypeOf<(shape: Mixed) => 1 | 2>();
assert.equal(pick({ kind: "a", a: 1 }), 1);
assert.equal(pick({ kind: true, b: 1 }), 2);
assert.equal(pick({ kind: false, c: 1 }), 2);
assert.equal(pick({ kind: null, d: 1 }), 2);
});
test("getTaggedUnionMatcherW: boolean and nullish tags widen to the union", () => {
// Arrange
type Maybe =
| { readonly kind: true; readonly a: number }
| { readonly kind: false; readonly b: number }
| { readonly kind: null; readonly c: number };
const factory = getTaggedUnionMatcherW<Maybe>()("kind");
// Act
const matcher = factory({
true: (s) => {
expectTypeOf(s).toEqualTypeOf<{
readonly kind: true;
readonly a: number;
}>();
assert.equal(s.kind, true);
return "yes" as const;
},
false: (s) => {
expectTypeOf(s).toEqualTypeOf<{
readonly kind: false;
readonly b: number;
}>();
assert.equal(s.kind, false);
return 0 as const;
},
null: (s) => {
expectTypeOf(s).toEqualTypeOf<{
readonly kind: null;
readonly c: number;
}>();
assert.equal(s.kind, null);
return null;
},
});
// Assert
expectTypeOf(matcher).toEqualTypeOf<(shape: Maybe) => "yes" | 0 | null>();
assert.equal(matcher({ kind: true, a: 1 }), "yes");
assert.equal(matcher({ kind: false, b: 1 }), 0);
assert.equal(matcher({ kind: null, c: 1 }), null);
});
// ============================================================================
// API: getTaggedUnionMatcher — ❌ Exhaustive (fallback) / ✔️ ReturnsStrict
// ============================================================================
test("getTaggedUnionMatcher: a fallback receives the unhandled members", () => {
// Arrange
const factory = getTaggedUnionMatcher<Shape>()("kind");
// Act
const area = factory(
{
circle: (s) => {
expectTypeOf(s).toEqualTypeOf<Circle>();
assert.equal(s.kind, "circle");
return 1 as const;
},
},
(s) => {
// The fallback sees only the members `circle` did not handle.
expectTypeOf(s).toEqualTypeOf<Square | Triangle>();
assert.ok(s.kind === "square" || s.kind === "triangle");
return 2 as const;
},
);
// Assert
expectTypeOf(area).toEqualTypeOf<(shape: Shape) => 1 | 2>();
assert.equal(area({ kind: "circle", radius: 1 }), 1);
assert.equal(area({ kind: "square", side: 1 }), 2);
assert.equal(area({ kind: "triangle", base: 1, height: 1 }), 2);
});
test("getTaggedUnionMatcher: an open discriminant keeps the fallback open", () => {
// Arrange
interface Message {
readonly kind: string;
readonly text: string;
}
const factory = getTaggedUnionMatcher<Message>()("kind");
// Act
const matcher = factory({ info: () => 1 as const }, (s) => {
// The map's literal key does not close an open discriminant, so the
// remainder stays `Message` and the fallback is not redundant.
expectTypeOf(s).toEqualTypeOf<Message>();
assert.equal(typeof s.text, "string");
return 2 as const;
});
// Assert
expectTypeOf(matcher).toEqualTypeOf<(shape: Message) => 1 | 2>();
assert.equal(matcher({ kind: "info", text: "" }), 1);
assert.equal(matcher({ kind: "warn", text: "" }), 2);
});
// ============================================================================
// API: getTaggedUnionMatcherW — ✔️ Exhaustive / ❌ ReturnsStrict
// ============================================================================
test("getTaggedUnionMatcherW: exhaustive pattern widens to the union of returns", () => {
// Arrange
const factory = getTaggedUnionMatcherW<Shape>()("kind");
// Act
const describe = factory({
circle: (s) => {
expectTypeOf(s).toEqualTypeOf<Circle>();
assert.equal(s.kind, "circle");
return "round" as const;
},
square: (s) => {
expectTypeOf(s).toEqualTypeOf<Square>();
assert.equal(s.kind, "square");
return 4 as const;
},
triangle: (s) => {
expectTypeOf(s).toEqualTypeOf<Triangle>();
assert.equal(s.kind, "triangle");
return true as const;
},
});
// Assert
// ❌ ReturnsStrict: mixed handler returns widen to their union.
expectTypeOf(describe).toEqualTypeOf<
(shape: Shape) => "round" | 4 | true
>();
assert.equal(describe({ kind: "circle", radius: 1 }), "round");
assert.equal(describe({ kind: "square", side: 1 }), 4);
assert.equal(describe({ kind: "triangle", base: 1, height: 1 }), true);
});
// ============================================================================
// API: getTaggedUnionMatcherW — ❌ Exhaustive (fallback) / ❌ ReturnsStrict
// ============================================================================
test("getTaggedUnionMatcherW: a fallback widens gaps into the union", () => {
// Arrange
const factory = getTaggedUnionMatcherW<Shape>()("kind");
// Act
const matcher = factory({ circle: () => 1 as const }, (s) => {
expectTypeOf(s).toEqualTypeOf<Square | Triangle>();
// Returning the member verbatim lets the `Assert` block check the
// exact value dispatch passed.
return s;
});
// Assert
expectTypeOf(matcher).toEqualTypeOf<
(shape: Shape) => 1 | Square | Triangle
>();
assert.equal(matcher({ kind: "circle", radius: 1 }), 1);
assert.deepEqual(matcher({ kind: "square", side: 1 }), {
kind: "square",
side: 1,
});
assert.deepEqual(matcher({ kind: "triangle", base: 1, height: 1 }), {
kind: "triangle",
base: 1,
height: 1,
});
});
// ============================================================================
// Factory contracts — calls that must not compile
// ============================================================================
test("getTaggedUnionMatcher factory rejects patterns outside its contract", () => {
// Arrange
const factory = getTaggedUnionMatcher<Shape>()("kind");
// Act / Assert — the calls below must not compile
factory({
circle: () => 1,
square: () => 2,
triangle: () => 3,
// @ts-expect-error `hexagon` is not a tag of Shape
hexagon: () => 4,
});
// @ts-expect-error a gap without a fallback is not exhaustive
factory({ circle: () => 1 });
// @ts-expect-error a `string` fallback does not fit the `number` handlers
factory({ circle: () => 1 }, () => "x");
// @ts-expect-error a fallback is redundant once the map covers the union
factory({ circle: () => 1, square: () => 2, triangle: () => 3 }, () => 0);
});
test("getTaggedUnionMatcher factory rejects a non-discriminant key", () => {
// Arrange
type Mixed =
| { readonly id: Date; readonly kind: "a" }
| { readonly id: Date; readonly kind: "b" };
// Act / Assert — the calls below must not compile
// @ts-expect-error `id` is a common key but its value is not a tag
getTaggedUnionMatcher<Mixed>()("id");
getTaggedUnionMatcher<Mixed>()("kind");
});
test("getTaggedUnionMatcher factory rejects non-exhaustive boolean and nullish maps", () => {
// Arrange
type Result =
| { readonly status: true; readonly a: number }
| { readonly status: false; readonly b: number };
type Maybe =
| { readonly status: null; readonly c: number }
| { readonly status: undefined; readonly d: number };
const resultFactory = getTaggedUnionMatcher<Result>()("status");
const maybeFactory = getTaggedUnionMatcher<Maybe>()("status");
// Act / Assert — the calls below must not compile
// @ts-expect-error only `true` is handled; `false` is not
resultFactory({ true: () => 1 });
// @ts-expect-error only `null` is handled; `undefined` is not
maybeFactory({ null: () => 1 });
// @ts-expect-error a fallback is redundant once both tags are handled
resultFactory({ true: () => 1, false: () => 2 }, () => 0);
});
test("getTaggedUnionMatcherW factory rejects patterns outside its contract", () => {
// Arrange
const factory = getTaggedUnionMatcherW<Shape>()("kind");
// Act / Assert — the calls below must not compile
factory({
circle: () => 1 as const,
square: () => 2 as const,
triangle: () => 3 as const,
// @ts-expect-error `hexagon` is not a tag of Shape
hexagon: () => 4 as const,
});
// @ts-expect-error a gap without a fallback is not exhaustive
factory({ circle: () => 1 as const });
factory(
// @ts-expect-error a fallback is redundant once the map covers the union
{
circle: () => 1 as const,
square: () => 2 as const,
triangle: () => 3 as const,
},
() => 0,
);
});
// ============================================================================
// Dispatch — runtime behavior
// ============================================================================
test("getTaggedUnionMatcher: an unhandled tag throws without a fallback", () => {
// Arrange — an open discriminant widens its handler map to an index
// signature, so the type system cannot prove the runtime map is exhaustive.
interface Message {
readonly kind: string;
}
const handlers: Record<string, (shape: Message) => number> = {
info: (shape) => {
// The full member reaches the handler, not its tag.
assert.equal(shape.kind, "info");
return 1;
},
};
const matcher = getTaggedUnionMatcher<Message>()("kind")(handlers);
// Act / Assert
assert.equal(matcher({ kind: "info" }), 1);
assert.throws(() => matcher({ kind: "warn" }), /Unhandled tag: warn/);
});
// ============================================================================
// Autocomplete — the language server is the oracle, not the type system
// ============================================================================
const REPO_ROOT = path.resolve(import.meta.dirname, "..");
const SHAPE_SOURCE = `type Shape = { kind: "a"; a: number } | { kind: "b"; b: number } | { kind: "c"; c: number };`;
interface LabelsProbe {
readonly name: string;
readonly factory: "getTaggedUnionMatcher" | "getTaggedUnionMatcherW";
readonly body: string;
readonly tail?: string;
readonly typeName?: string;
readonly typeSource?: string;
}
const labelsFor = ({
name,
factory,
body,
tail = "",
typeName = "Shape",
typeSource = SHAPE_SOURCE,
}: LabelsProbe): Promise<readonly string[]> => {
const session = new LspSession(REPO_ROOT);
const target: CompletionTarget = {
file: `src/__autocomplete_${name}.ts`,
source: [
`import { ${factory} } from "./index.ts";`,
typeSource,
`const m = ${factory}<${typeName}>()("kind")({`,
body,
`}${tail});`,
"",
].join("\n"),
};
return session
.completionLabelsAt(target)
.then((result) => result.labels)
.finally(() => session.close());
};
test("autocomplete: a tagged-union pattern requires the tags", () => {
// Arrange
const name = "tagged_union_fresh";
// Act
const labels = labelsFor({
name,
factory: "getTaggedUnionMatcher",
body: " /*COMPLETE*/",
});
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["a", "b", "c"]);
});
});
test("autocomplete: a handled tag drops out of the popup", () => {
// Arrange
const name = "tagged_union_after_key";
// Act
const labels = labelsFor({
name,
factory: "getTaggedUnionMatcher",
body: " a: () => 1,\n /*COMPLETE*/",
});
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["b", "c"]);
});
});
test("autocomplete: a fallback makes the remaining tags optional", () => {
// Arrange
const name = "tagged_union_with_fallback";
// Act
const labels = labelsFor({
name,
factory: "getTaggedUnionMatcher",
body: " /*COMPLETE*/",
tail: ", () => 0",
});
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["a?", "b?", "c?"]);
});
});
// `true`/`false`/`null`/`undefined` tags are not property keys, so the popup
// must offer them by name through the `PatternKey` projection.
const STATUS_SOURCE = `type Status = { kind: true; a: number } | { kind: false; b: number } | { kind: null; c: number } | { kind: undefined; d: number };`;
test("autocomplete: boolean and nullish tags are offered by name", () => {
// Arrange
const name = "tagged_union_boolean_nullish";
// Act
const labels = labelsFor({
name,
factory: "getTaggedUnionMatcher",
typeName: "Status",
typeSource: STATUS_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`
// may serve as the discriminant.
const MIXED_SOURCE = `type Mixed = { id: Date; kind: "a"; a: number } | { id: Date; kind: "b"; b: number };`;
const discriminantLabelsFor = ({
name,
factory,
typeName,
typeSource,
}: {
readonly name: string;
readonly factory: "getTaggedUnionMatcher" | "getTaggedUnionMatcherW";
readonly typeName: string;
readonly typeSource: string;
}): Promise<readonly string[]> => {
const session = new LspSession(REPO_ROOT);
const target: CompletionTarget = {
file: `src/__autocomplete_${name}.ts`,
source: [
`import { ${factory} } from "./index.ts";`,
typeSource,
`const m = ${factory}<${typeName}>()(/*COMPLETE*/);`,
"",
].join("\n"),
};
return session
.completionLabelsAt(target)
.then((result) => result.labels)
.finally(() => session.close());
};
test("autocomplete: the discriminant key is offered at the key argument", () => {
// Arrange
const name = "tagged_union_discriminant";
// Act
const labels = discriminantLabelsFor({
name,
factory: "getTaggedUnionMatcher",
typeName: "Mixed",
typeSource: MIXED_SOURCE,
});
// Assert
return labels.then((result) => {
// The argument position also offers every global identifier, so assert
// inclusion of the discriminant and exclusion of the non-tag key.
assert.ok(result.includes('"kind"'));
assert.ok(!result.includes('"id"'));
});
});
+142
View File
@@ -0,0 +1,142 @@
import type { Exact, UnknownRecord } from "type-fest";
import type {
HandlerMap,
Matchable,
PatternKey,
PatternParam,
PatternReturns,
RedundantFallback,
UnaryFn,
} from "./matcher-shared.ts";
// A tagged union is discriminated by one property whose values are the tags (a
// `Matchable`). `string` and `number` tags key a handler map directly;
// `boolean`, `null` and `undefined` are admitted too but are not property keys,
// so they go through the `PatternKey` projection. `symbol` has no literal syntax
// to write a handler under, and `bigint` is not a property key.
// The discriminant values of `T` under `K`. `Extract` keeps the finite literal
// tags and leaves a widened `string`/`number` as itself, so an open universe
// keeps an open fallback.
type Tags<T extends object, K extends keyof T> = Extract<T[K], Matchable>;
// The keys of `T` that can act as a discriminant. `getTaggedUnionMatcher<T>()`
// accepts only these, so the factory rejects a key whose values are not tags.
type Discriminated<T extends object> = {
[K in keyof T]: T[K] extends Matchable ? K : never;
}[keyof T];
// The member(s) of `T` tagged `V`. `Extract` distributes over the union, so a
// duplicated tag maps to a union of members rather than silently dropping one.
// Keyed by `PatternKey`, so `boolean`/`null`/`undefined` tags can key a mapped
// type; `PatternParam` inverts the projection to recover the member.
type MapTaggedUnion<T extends object, K extends keyof T> = {
[P in PatternKey<Tags<T, K>>]: Extract<T, Record<K, PatternParam<P>>>;
};
type Handlers<T extends object, K extends keyof T, R> = {
[P in PatternKey<Tags<T, K>>]: UnaryFn<MapTaggedUnion<T, K>[P], R>;
};
// The members `Handled` covers. Mapping over the projected keys keeps every
// index within `MapTaggedUnion`'s keys, and the conditional drops a stray key
// outside `T` so it cannot widen the remainder. The remainder is
// `Exclude<T, …>`, mirroring the primitive-union matcher's
// `Exclude<T, PatternParam<keyof Handled>>`.
type HandledMembers<T extends object, K extends keyof T, Handled> = {
[P in PatternKey<Tags<T, K>>]: P extends keyof Handled
? MapTaggedUnion<T, K>[P]
: never;
}[PatternKey<Tags<T, K>>];
// The fallback is a *second argument*, not a property of the handler map, so
// its parameter can be the remainder the map left open. See development/library.md.
type Fallback<T extends object, K extends keyof T, Handled, R> = UnaryFn<
Exclude<T, HandledMembers<T, K, Handled>>,
R
>;
// A fallback is redundant once the handler map covers every tag of `T`. Folded
// into `Handled`'s own (self-referential) constraint so it is checked *after*
// inference; see the primitive-union matcher for why a conditional in the fallback's
// parameter is evaluated too early.
type MustBePartial<T extends object, K extends keyof T, Handled> =
PatternKey<Tags<T, K>> extends keyof Handled ? RedundantFallback : unknown;
// TypeScript does not apply the excess-property check to a generic constraint,
// so `Exact` restores it for the generic forms.
// Strict returns: one common `R`. Overload order is load-bearing:
// #1 Handlers (first) -> the exhaustive form and the autocomplete popup
// #2 Fallback (last) -> accepts a partial handler map plus a fallback
interface TaggedUnionMatcherStrict<T extends object, K extends keyof T> {
<R>(handlers: Handlers<T, K, R>): UnaryFn<T, R>;
<
R,
Handled extends Exact<Partial<Handlers<T, K, R>>, Handled> &
MustBePartial<T, K, Handled>,
>(
handlers: Handled & Partial<Handlers<T, K, R>>,
fallback: Fallback<T, K, Handled, R>,
): UnaryFn<T, R>;
}
// Widened returns: the union of every handler's return type. `P` is inferred
// from the whole handler map, whose closed constraint supplies the
// contextual/autocomplete type.
interface TaggedUnionMatcherWidening<T extends object, K extends keyof T> {
<P extends Exact<Handlers<T, K, unknown>, P>>(
handlers: P,
): UnaryFn<T, PatternReturns<P>>;
<
R,
Handled extends Exact<Partial<Handlers<T, K, unknown>>, Handled> &
MustBePartial<T, K, Handled>,
>(
handlers: Handled,
fallback: Fallback<T, K, Handled, R>,
): UnaryFn<T, PatternReturns<Handled> | R>;
}
// The key-taking step of the curried factory. Naming it lets the factory return
// `dispatch` directly, the tacit twin of the primitive-union factory's bare
// `=> dispatch`.
type TaggedUnionMatcherFactory<T extends object> = <K extends Discriminated<T>>(
k: K,
) => TaggedUnionMatcherStrict<T, K>;
type TaggedUnionMatcherWideningFactory<T extends object> = <
K extends Discriminated<T>,
>(
k: K,
) => TaggedUnionMatcherWidening<T, K>;
const dispatch =
(k: PropertyKey) =>
(handlers: HandlerMap, fallback?: UnaryFn<never, unknown>) =>
(shape: object): unknown => {
// `object` carries no index signature, so the read needs the assertion;
// the factory admits only keys whose values are tags, and the map keys
// them by `PatternKey`, so the result is narrowed to the map's key space.
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
const tag = (shape as UnknownRecord)[k] as string | number;
return (
handlers[tag] ??
fallback ??
(() => {
throw new Error(`Unhandled tag: ${String(tag)}`);
})
)(
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
shape as never,
);
};
export const getTaggedUnionMatcher = <
T extends object,
>(): TaggedUnionMatcherFactory<T> => dispatch;
export const getTaggedUnionMatcherW = <
T extends object,
>(): TaggedUnionMatcherWideningFactory<T> => dispatch;
@@ -1,4 +1,3 @@
/* oxlint-disable typescript/no-floating-promises -- expectTypeOf() is a sync type-assertion library that the type-aware linter misidentifies as a promise */
import { strict as assert } from "node:assert";
import path from "node:path";
import { test } from "node:test";