53 Commits
Author SHA1 Message Date
tmu 434c3a2222 🔀 Merge feature/tagged-union-matcher into main
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 27s
CI / publish (push) Skipped
CI / maintain (push) Failing after 15s
2026-09-21 23:29:48 +00:00
tmu 6280061737 📝 Note the primitive-union rename in the backlog 2026-09-21 23:24:06 +00:00
tmu 23a2776d55 📝 Note the widened-string-union matcher test 2026-09-21 23:21:19 +00:00
tmu 937d84ea48 ♻️ Share matcher internals 2026-09-21 23:15:09 +00:00
tmu 6f4f509b69 ⚡ Inline the tag read into dispatch 2026-09-21 23:02:56 +00:00
tmu 3f22e5ca01 🔥 Drop unused unified-signature disables 2026-09-21 22:53:42 +00:00
tmu 78e9af600d ✅ Cover the discriminant-key popup 2026-09-21 22:36:30 +00:00
tmu 8ab3c6dbc6 📝 Document the tagged-union matcher 2026-09-21 22:14:22 +00:00
tmu c0fc090ee4 ✨ Add a matcher for tagged unions 2026-09-21 22:14:10 +00:00
tmu a4b4618c9f 🚀 Release 0.5.0
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 30s
CI / maintain (push) Failing after 14s
CI / publish (push) Failing after 17s
2026-09-21 13:11:27 +00:00
tmu bf711cf9fc 🔀 Merge chore/test-handler-runtime-arguments into main 2026-09-21 13:09:10 +00:00
tmu 9e03c52473 ✅ Assert the shape each handler receives
A handler's `expectTypeOf(shape)` only proved the type the compiler inferred;
nothing observed the argument `dispatch` passed. Passing `String(shape)` at the
call site therefore kept the whole suite green while breaking every key whose
property name differs from its value (`true`, `null`, `1`).

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

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

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

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

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

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

src/index.ts re-exports the two factories; the prototype scratch files that
explored the alternatives are removed.
2026-09-17 20:59:09 +00:00
tmu 02634f41de 🔀 Merge chore/test-util-home into main 2026-09-17 13:33:41 +00:00
tmu 9da5b5dbf2 📝 Note the test-helper home in the changelog 2026-09-17 13:23:36 +00:00
tmu dc57529f0f 📝 Correct the upstream claim in branch.sh
`main` tracks `origin` (ssh); the comment claiming it tracked an
https companion remote was stale.
2026-09-17 13:23:34 +00:00
tmu 484e5c8ca5 📝 Allow the floating-promise header in test files
Agents may add the fixed file-level typescript/no-floating-promises
oxlint-disable header at the top of a `*.test.ts` file — the
expectTypeOf() misidentification is a documented false positive
(development/testing.md § Known issues) and the await/void workarounds
collide with eslint/no-void. Every other suppression stays the human
last resort it was.
2026-09-17 13:23:32 +00:00
tmu c51e32d6a6 📝 Record the test-helper home decision
development/testing.md § Test helpers: why name beats path under
import/no-relative-parent-imports, why the key needs `#`, why the
`__tests__` folder name, and what was rejected (flat placement, tsconfig
paths, rule exemptions).
2026-09-17 13:23:29 +00:00
tmu 9e5522c131 🚚 Home test helpers behind #test-utils/*
src/util/__tests__/ holds shared test helpers, reached from anywhere
in the source tree as `#test-utils/<name>.ts` through
package.json#imports. A bare `~/…` is not a valid imports key — Node
requires `#` — and a direction-free specifier keeps
import/no-relative-parent-imports from ever firing. The `__tests__`
folder name is the pattern tsconfig.build.json already excludes, so
helpers can never ship. Lint scoping: import/no-nodejs-modules off for
the folder (the probe spawns a language server) and
typescript/promise-function-async off to match its promise-returning
style. The LSP probe moves in from scripts/; a smoke test pins the
specifier's resolution and typing without starting a server.
2026-09-17 13:23:27 +00:00
20 changed files with 2275 additions and 340 deletions

No files matched your search

+11 -2
View File
@@ -22,7 +22,8 @@
"eslint/sort-imports": "off",
"import/consistent-type-specifier-style": "off",
"unicorn/prefer-export-from": "off",
"typescript/method-signature-style": "off"
"typescript/method-signature-style": "off",
"typescript/promise-function-async": "off"
},
"options": { "typeAware": true },
"env": { "builtin": true, "es2024": true, "node": true },
@@ -33,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"
}
},
{
@@ -41,6 +44,12 @@
"rules": {
"import/no-nodejs-modules": "off"
}
},
{
"files": ["src/util/__tests__/**"],
"rules": {
"import/no-nodejs-modules": "off"
}
}
],
"ignorePatterns": ["dist", "node_modules", "coverage"]
+6 -5
View File
@@ -26,9 +26,10 @@ Don't silence the type system to force a green run. As an agent these are forbid
- `// @ts-nocheck`, `// @ts-ignore`, `// @ts-expect-error`
- `// 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. 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>`.
@@ -42,7 +43,9 @@ Never start a long-lived / blocking process such as `npm run watch`. It runs unt
### Working on tasks
- **Task with subtasks** (a task that has indented children): create the branch with `npm run create:branch -- <prefix>/<desc>`, inferring the prefix from the task content (`feature/…` / `fix/…` / `chore/…`) — do not hand-write `git switch -c`, the command enforces the clean-tree / current-`main` / green-baseline precondition. Work on each subtask with commits, then present a concise handover for the user to review. Use this fixed shape:
Every task — with or without subtasks — goes through the complete branching model:
- Create the branch with `npm run create:branch -- <prefix>/<desc>`, inferring the prefix from the task content (`feature/…` / `fix/…` / `chore/…`) — do not hand-write `git switch -c`, the command enforces the clean-tree / current-`main` / green-baseline precondition. Work with commits (each subtask gets one or more), then present a concise handover for the user to review. Use this fixed shape:
```md
## Handover — <branch>
@@ -54,9 +57,7 @@ Never start a long-lived / blocking process such as `npm run watch`. It runs unt
Once the user has no further objections, merge back: `npm run create:finish` (on the branch — it merges `--no-ff`, runs `npm run verify`, and deletes the branch). The branching model is documented in [CONTRIBUTING.md § Branching model](./CONTRIBUTING.md#branching-model).
- **Leaf task** (no indented children): implement on the current branch and commit.
In both cases, follow [CONTRIBUTING.md § Testing discipline (type-driven)](./CONTRIBUTING.md#testing-discipline-type-driven). Each subtask gets one or more commits.
Follow [CONTRIBUTING.md § Testing discipline (type-driven)](./CONTRIBUTING.md#testing-discipline-type-driven) throughout.
## Read these
+20 -1
View File
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- add `getTaggedUnionMatcher` / `getTaggedUnionMatcherW` for discriminated unions
## [0.5.0] - 2026-09-21
- reject a fallback when the handler map already covers the universe
- allow boolean, null and undefined in the matcher universe
## [0.4.0] - 2026-09-20
- move the `_` fallback out of handlers
## [0.3.0] - 2026-09-18
- condensed primitive matcher factories down to 2 from formerly 4
- house shared test helpers under `src/util/__tests__/`
## [0.2.0] - 2026-09-16
- allow ternaries and lowercase comments in oxlint
@@ -54,7 +70,10 @@ 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.2.0...main
[Unreleased]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.5.0...main
[0.5.0]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.4.0...0.5.0
[0.4.0]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.3.0...0.4.0
[0.3.0]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.2.0...0.3.0
[0.2.0]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.8...0.2.0
[0.1.8]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.7...0.1.8
[0.1.7]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.6...0.1.7
+11 -2
View File
@@ -66,7 +66,15 @@ 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
exception —
the language server, not the type system, is the oracle (see
[development/testing.md § Autocomplete](./development/testing.md#autocomplete)).
Each test body follows **AAA (Arrange–Act–Assert)** with labeled blocks
separated by a blank line: `// Arrange` sets up the inputs (e.g. the matcher
factory), `// Act` exercises the subject once from them (not a second
@@ -150,7 +158,8 @@ 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
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.**
+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).
+38 -3
View File
@@ -27,11 +27,45 @@ v1.0:
☐ Achieve 100% branch coverage on `src/primitive.ts`
☐ Achieve 100% branch coverage on `src/index.ts`
Bugs:
Testing:
✔ Cover autocomplete with real completion test cases in the suite @medium @done
→ `src/util/__tests__/lsp-completion.ts` (`#test-utils/…`) is the helper; the suite asserts its labels (see development/testing.md § Autocomplete)
→ wire it into `node --test` so a test asserts the offered labels
→ note: `Parameters<typeof factory>[0]` resolves only the *last* overload; use `@ts-expect-error` call sites for factory negatives, not `not.toExtend<Parameters<…>>`
✔ Drive the LSP completion helper through the LSP protocol library instead of a hand-rolled JSON-RPC client @medium @done
✔ Add `vscode-languageserver-protocol` and record the tooling decision @done
✔ Rewrite `src/util/__tests__/lsp-completion.ts` onto `createMessageConnection` and typed requests @done
✔ Update `development/testing.md § Autocomplete` for the new client @done
✔ Run `npm run verify` and check the task off @done
☐ Test a literal union widened with `(string & {})` — `"red" | "green" | "yellow" | (string & {})` @medium
→ autocomplete should still offer the literals; arbitrary strings stay assignable and fall through to the fallback
Matcher:
✔ Clean up: adopt the 3-overload matcher (`src/prototype-ac2.ts`) and delete the prototypes @high @done
→ `getMatcher` / `getMatcherW`, each with overloads `ExhaustiveLoose` → `Fallback` → `Handlers` (order is load-bearing)
→ fold into `src/primitive.ts` / the public API; drop `src/prototype*.ts`
✔ `_` should receive only the unhandled `T` keys, not all of `T` @medium @done
→ the fallback is now a second argument: `(handlers, (s) => …)`, `s: Exclude<T, keyof handlers>`
✔ A fallback for an already-exhaustive handler map must be a compile error @medium @done
→ rejected by an F-bounded constraint on `Handled` (checked *after* inference); a conditional in the fallback parameter is evaluated too early and breaks contextual typing
✔ Implement matcher with similar API like matcher from primitive.ts @high @done
→ `getTaggedUnionMatcher` / `getTaggedUnionMatcherW`, curried on the discriminant key
→ fallback is the second argument; `_` removed
☐ Rename `primitive` to `primitive-union` and `getMatcher` to `getPrimitiveMatcher` @medium
→ `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
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
→ needs the primitive matcher's `PatternKey` / `PatternParam` projection; see development/library.md § Tagged-union matcher
Documentation:
☐ Bring README.md back to its previous form — synopsis and examples restored, in the correct place
@@ -41,6 +75,7 @@ Documentation:
☐ Add comparison section vs. other TS pattern-matching libs in Readme.md
☐ Write migration guide for users coming from discriminated unions
☐ Create backlog tasks for implementation
✔ Document the matcher design paths in `development/` — union vs overload merge, inferred universe (`NoInfer`), conditional `RequireKeys`, cases-first — and why each was abandoned @done
☐ Validate code fences in Markdown (start with README.md) — compile the TypeScript examples against `src/` so the docs cannot drift from the API
Workflow:
+181 -1
View File
@@ -3,4 +3,184 @@
The type-level design of the public API and the limitations it carries. The
user-facing reference is [README § API](../README.md#api).
Currently the library is placeholder code.
The matchers are implemented in `src/primitive.ts` (`getMatcher` /
`getMatcherW`) and `src/tagged-union.ts` (`getTaggedUnionMatcher` /
`getTaggedUnionMatcherW`), re-exported from `src/index.ts`; the rest of the
library is placeholder code.
## Matcher shape
#### Decision (2026-09)
A factory takes the universe and returns a builder; the builder takes a handler
map and an optional fallback:
```ts
const matcher = getMatcher<"a" | "b">()({ a: (s) => …, b: (s) => … });
const fallback = getMatcher<"a" | "b" | "c">()({ a: (s) => … }, (s) => …);
```
Exhaustive or fallback is decided **at the call site**, by whether the second
argument is present. The fallback's parameter is the remainder
`Exclude<T, keyof Handled>`. Only the return-strictness axis remains, so there
are two factories:
- `getMatcher` — one common `R`; the fallback must fit it;
- `getMatcherW` — the union `PatternReturns<Handled> | R`.
Each factory is two overloads whose order is load-bearing:
1. `Handlers<T, R>` — the exhaustive form, and the contextual type of the
handler-map popup;
2. `Handled extends Exact<Partial<Handlers<T, R>>, Handled>` intersected with
`MustBePartial<T, Handled>`, plus `Fallback<T, Handled, R>` — a partial
handler map plus the fallback, rejected when the map already covers `T`.
#### Why
- **The fallback is an argument, not a property.** TypeScript fixes a property's
contextual type before it infers its sibling keys, so `_: (s) => …` in the
handler map can only see all of `T`, never `Exclude<T, keyof Handled>`. A later
argument is contextually typed from inference on an earlier one, so the split
is what makes the remainder expressible.
- **The redundant-fallback guard is an F-bounded constraint.** A map that
already covers `T` plus a fallback is rejected by folding
`MustBePartial<T, Handled>` into `Handled`'s own constraint. The guard is
checked _after_ `Handled` is inferred, so the contextual pass that types the
handler callbacks survives. The obvious conditional
`Exclude<T, keyof Handled> extends never ? …` in the fallback's parameter
type is evaluated while `Handled` is still its constraint and rejects every
partial map whose callbacks are context-sensitive.
- **Overload order keeps both messages.** #1 supplies the contextual type
(`a, b, c`); #2 accepts a partial map once a fallback is present, so its popup
is optional (`a?, b?, c?`). A gap without a fallback is reported against #1.
- **`R` needs an inference site.** `R` inside the `Exact<…>` constraint is not
one, so `handlers: Handled & Partial<Handlers<T, R>>` re-adds it; without that
`R` collapses to `unknown` when the handler params are inferred.
- **`Exact` restores the excess-property check.** TypeScript skips it for a
generic constraint, so without `Exact` the handler map accepts keys outside
`T`.
- Two factories, not four: the fallback is an argument, not a separate API.
#### Rejected
- **Single-object `_`** (the former shape). `_` sees only all of `T`; the
remainder is not expressible there, and an exhaustive map plus `_` was
accepted.
- **Curried handlers-first** — `(handlers)(fallback)`. Rejected: two calls for
the common case. It is not needed for the redundant-fallback guard, which the
F-bounded constraint already provides (see Why).
- **`this` / HKT self-reference.** `this` is post-construction (method bodies,
return positions); a parameter's contextual type is pre-construction.
`keyof this` in an interface method is the interface, not the literal.
- **Variance / `const` type parameters / `NoInfer` / `unique symbol` brands /
defaulted type-param guards.** None change inference or evaluation order;
`in`/`out` on the handler map broke contextual typing outright. `NoInfer`
specifically leaks into the emitted `.d.ts`, raising the consumer floor to
TypeScript 5.4 (README promises `>= 5.0`).
- **Union merge**, **overload merge with only the exhaustive arm last**,
**inferred universe**, **conditional `RequireKeys`**, **cases-first curried** —
decided against while the API was single-object; their reasons (reported
near-miss member, no `_` in the exhaustive popup, `NoInfer`/floor, `keyof P`
counts optional keys, not pipe-friendly) hold where they still apply.
#### Known issue
- `PatternReturns` must be
`ReturnType<Extract<ValueOf<P>, (...args: never[]) => unknown>>` so it survives
the closed, partly-optional `P` constraints.
- `Parameters<typeof factory>[0]` resolves only the **last** overload, so it is
not a sound "rejected" oracle for a factory. Factory-negative tests use
`@ts-expect-error` call sites (the test file only — the general ban stands).
## Shared internals
#### Decision (2026-09)
`src/matcher-shared.ts` holds the four universe-agnostic pieces both matchers
use: `UnaryFn`, `PatternReturns`, `RedundantFallback`, `HandlerMap`.
#### Why
- `RedundantFallback`'s property name is the diagnostic, so one definition
keeps the two matchers' message from drifting; the other three appear verbatim
in both public signatures.
#### Rejected
- **A generic `Matcher<Universe>` over the interface pair, `Handlers`,
`Fallback` and `MustBePartial`.** Each is built from its own universe
(`PatternKey`/`PatternParam` vs `Tags`/`MapTaggedUnion`); abstracting over the
F-bounded `Handled` constraint that makes the remainder work risks the
contextual typing it exists to preserve.
## Tagged-union matcher
#### Decision (2026-09)
`getTaggedUnionMatcher` / `getTaggedUnionMatcherW` mirror the primitive 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 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 dispatch's `shape as string | number`.
- **`MapTaggedUnion` distributes with `Extract`.** A duplicated tag yields a
union of members instead of dropping one.
#### Known issue
- Tags are `string | number` only. A `boolean` / `null` / `undefined`
discriminant (`{ ok: true } | { ok: false }`) is rejected by `Discriminated`,
because those values are not property keys; supporting them needs the
primitive matcher's `PatternKey` / `PatternParam` projection.
- A member's tag must be unique across the union; two members with the same tag
collapse to a union under one handler.
## 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).
#### 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).
+141 -6
View File
@@ -30,12 +30,47 @@ before the implementation.
- Testing the type only: it would not catch handler dispatch or the `_`
fallback (see `src/primitive.test.ts`).
The runner is `node --test --strip-types "src/**/*.test.ts"` and the tiers are in
The runner is `node --test --strip-types "src/**/*.test.ts"` and the tiers are
in
[CONTRIBUTING.md § Development commands](../CONTRIBUTING.md#development-commands).
`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)).
## 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
@@ -63,11 +98,111 @@ separated by a blank line; an empty block drops its label.
- Unlabeled ordering (bare blank lines): the seams still have to be found by
reading; the labels cost nothing.
## Test helpers
The rule is enforced by `import/no-relative-parent-imports`; this section records
why the mechanism is shaped like this.
#### Decision (2026-09)
A shared test helper — code that scattered `*.test.ts` files import to do their
testing — lives under `src/util/__tests__/` and is addressed by the
`#test-utils/…` self-reference (`package.json#imports`:
`"#test-utils/*": "./src/util/__tests__/*"`), never by a relative path:
```ts
import { LspSession } from "#test-utils/lsp-completion.ts";
```
#### Why
- The lint rule bans upward (`../`) imports, and a cross-cutting helper can
always be placed above _some_ scattered consumer, wherever it goes. Name
beats path: a `#test-utils/…` specifier has no direction, so the rule never
fires and file moves only touch the one mapping in `package.json`.
- `#…` is Node's reserved prefix for _private_ subpath imports: publishing
`package.json` leaks nothing and resolves nothing for consumers.
- `__tests__` as the folder name is not about tests living there; it is the
directory pattern `tsconfig.build.json` already excludes, so a helper can
never be emitted into `dist/` and shipped by accident.
- Node's own resolver handles `#…` under `--strip-types`, and `tsc` resolves it
via the same `imports` field — one mechanism for runtime and type gate, no
loader needed.
- The helper uses `node:` builtins (it drives a language server), so
`import/no-nodejs-modules` is off for `src/util/__tests__/**` in
`.oxlintrc.json` — the same exception the `scripts/**` scope carries.
- Helpers carry no library coupling: their tests probe in-memory documents
whose contextual types are written inline, so only the helper's own contract
(marker handling, position, label extraction) is under test. A test that
asserts a _matcher's_ popup belongs with the matcher.
#### Rejected
- A helper beside the tests (`src/util/test.ts`, flat `src/`): composes only
while `src/` stays flat; the first nested test reaching it reintroduces the
banned upward import.
- Bare `~/…` specifier: not valid in `imports` (keys must start with `#`) —
resolution fails at runtime with `ERR_MODULE_NOT_FOUND`. A `#~/…` “home”
shorthand was dropped in review; `#test-utils/…` states what it is.
- tsconfig `paths` alias: resolves for `tsc` but not for plain
`node --test --strip-types` (no loader hook), breaking the fast tier.
- Turning `import/no-relative-parent-imports` off for test files: the rule
still guards non-test helpers importing each other, and the exemption is
only needed for the one specifier the mapping already solves cleanly.
## Autocomplete
#### Decision (2026-09)
Completion is verified by driving the repo's own language server
(`tsc --lsp --stdio`, the same server pi's LSP extension talks to) through
the test helper `#test-utils/lsp-completion.ts`
(`src/util/__tests__/lsp-completion.ts`), not through the type system:
```sh
node --strip-types src/util/__tests__/lsp-completion.ts <file> [<marker>]
```
The script prints the labels the server offers at a `/*COMPLETE*/` marker inside
`<file>` (the marker is stripped before the document is sent). Its `LspSession`
is imported by `src/util/__tests__/lsp-completion.test.ts` — which tests the
helper itself against inline documents, never the library's code — and by
`src/primitive.test.ts`, where the same probe asserts the matcher's popup;
the CLI is for manual inspection.
#### Why
- Completion is a contextual-type property: it depends on which overload
signature TypeScript picks for the object literal, and no type-level assertion
observes that.
- `Parameters<typeof factory>[0]` resolves only the _last_ overload, so it is
not the popup's contextual type either — see
[library.md § Matcher shape](./library.md#matcher-shape).
- The server is the only ground truth; the script reproduces what the editor
shows.
#### Rejected
- **`expect-type` would not work**: there is no operator for “the popup offers
these labels”. `toExtend` / `toEqualTypeOf` test assignability and cannot say
which overload supplied the contextual type.
- **Checking by hand in the editor**: not reproducible in review or by an agent.
- **`@ts-expect-error` at a completion position**: it asserts the absence of a
compile error, not the presence of specific labels.
#### Known issue
- Each test spawns its own `tsc` server so the tests share no state and pass in
any order; the file is an integration test (~1.6 s) that needs `node_modules`.
`didOpen` is handled in order before the completion request, so no settle
delay is needed.
- The server answers some requests with a string id (`client/registerCapability`);
the client must tolerate `string | number` ids or the server stalls.
## 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)).
+53 -11
View File
@@ -24,6 +24,8 @@ in [package.json](../package.json).
- **@spences10/pi-lsp** — read-only LSP code intelligence for AI agents
(project-local `.pi/settings.json`); talks to this repo's TypeScript 7 via
`tsc --lsp --stdio`.
- **vscode-languageserver-protocol** — LSP client and protocol types for the
autocomplete test helper (`src/util/__tests__/lsp-completion.ts`).
When each runs is in
[CONTRIBUTING.md § Feedback tiers](../CONTRIBUTING.md#feedback-tiers).
@@ -104,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.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
@@ -247,6 +257,38 @@ project.
- The same script works by hand (whole project) and staged (scoped), so there is
no second command to maintain.
## Language server tooling
### `vscode-languageserver-protocol` backs the autocomplete helper
#### Decision (2026-09)
The autocomplete helper (`src/util/__tests__/lsp-completion.ts`) drives
`tsc --lsp --stdio` through `vscode-languageserver-protocol`'s
`createMessageConnection` and its typed request / notification objects, instead
of a hand-rolled JSON-RPC client.
#### Why
- Framing, `Content-Length` parsing, the pending-request map and server-request
dispatch are protocol plumbing the helper only reimplemented; the official
client owns them and tolerates the server's `string | number` ids.
- `InitializeRequest`, `CompletionRequest`, `DidOpenTextDocumentNotification`,
… carry their parameter and result types, so `CompletionList` / `CompletionItem`
replace the helper's ad-hoc shape guards.
- The `./node` entry re-exports `vscode-jsonrpc/node`, so one devDependency
supplies both the transport and the protocol types. It is test-only and never
ships (`files` publishes `dist/` only).
#### Rejected
- `vscode-languageclient`: the editor-side client with a full feature registry
— far more than a test helper needs.
- Generic JSON-RPC (`jsonrpc-lite`, `jayson`): still no LSP types, so they
replace framing only and leave the typed protocol surface unimplemented.
- Keeping the hand-rolled client: the low-level shape is the maintenance cost
the helper exists to remove, and it must be re-audited against the server.
## Editor and agent tooling
### VSCode integration
+32 -3
View File
@@ -1,12 +1,12 @@
{
"name": "tiny-pattern-ts",
"version": "0.2.0",
"version": "0.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tiny-pattern-ts",
"version": "0.2.0",
"version": "0.5.0",
"license": "MIT",
"dependencies": {
"type-fest": "^5.9.0"
@@ -27,7 +27,8 @@
"oxlint": "^1.83.0",
"oxlint-tsgolint": "^7.0.2001",
"publint": "^0.3.24",
"typescript": "^7.0.2"
"typescript": "^7.0.2",
"vscode-languageserver-protocol": "^3.18.3"
},
"engines": {
"node": ">=26"
@@ -4822,6 +4823,27 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/vscode-jsonrpc": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.2.tgz",
"integrity": "sha512-SbQSV9yRemARxeXw6LU5sS6Zq0e9/DgCCX5yelH263ZQWukbTk8EF8fjTrr1dziasf4GwlJbvTwFnTrnQFWZXQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/vscode-languageserver-protocol": {
"version": "3.18.3",
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.3.tgz",
"integrity": "sha512-DF49+WeV5py4zO5hhobp60jjsDSK0lAqA0OuKBLBvp423HPWQcCbhZz3JgyfIewsEz2f8U+X75xNIFHdiXZm2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"vscode-jsonrpc": "9.0.2",
"vscode-languageserver-types": "3.18.3"
}
},
"node_modules/vscode-languageserver-textdocument": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.14.tgz",
@@ -4829,6 +4851,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/vscode-languageserver-types": {
"version": "3.18.3",
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.3.tgz",
"integrity": "sha512-XIlzJ7Qp/jzSI1ds7/FwPAWrPeTZA7pAtlW4hdJ1J6xXWJL6dR9QYnDhJOdLzdKhUQ5Mm6mvUMw+3DcOQQasPw==",
"dev": true,
"license": "MIT"
},
"node_modules/vscode-uri": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz",
+6 -2
View File
@@ -1,6 +1,6 @@
{
"name": "tiny-pattern-ts",
"version": "0.2.0",
"version": "0.5.0",
"description": "Pattern matching for TypeScript/ESM environments (F#-style, not regex)",
"keywords": [
"adt",
@@ -27,6 +27,9 @@
],
"type": "module",
"sideEffects": false,
"imports": {
"#test-utils/*": "./src/util/__tests__/*"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
@@ -84,7 +87,8 @@
"oxlint": "^1.83.0",
"oxlint-tsgolint": "^7.0.2001",
"publint": "^0.3.24",
"typescript": "^7.0.2"
"typescript": "^7.0.2",
"vscode-languageserver-protocol": "^3.18.3"
},
"engines": {
"node": ">=26"
+3 -3
View File
@@ -73,9 +73,9 @@ git show-ref --verify --quiet "refs/heads/${BASE}" || {
exit 1
}
# Derive the remote rather than hardcoding it: this repo has `origin` (ssh) and
# `origin_https`, and `main` tracks the latter — `git fetch origin main` would
# check currency against a ref that is never updated here.
# Derive the remote rather than hardcoding it: `main` tracks `origin` (ssh)
# here; a hardcoded name would check currency against a ref that may not
# exist on a differently configured clone.
# `--quiet` echoes the unresolved `main@{upstream}` literal to stdout on
# failure, so it cannot be paired with a `$(...) || fallback`; the non-quiet
# form prints nothing on failure and the fallback runs.
+4 -5
View File
@@ -1,6 +1,5 @@
export { getMatcher, getMatcherW } from "./primitive.ts";
export {
getPrimitiveUnionMatcher,
getPrimitiveUnionMatcherPartial,
getPrimitiveUnionMatcherPartialW,
getPrimitiveUnionMatcherW,
} from "./primitive.ts";
getTaggedUnionMatcher,
getTaggedUnionMatcherW,
} from "./tagged-union.ts";
+28
View File
@@ -0,0 +1,28 @@
import type { ValueOf } from "type-fest";
// The primitive 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;
// `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
>;
+634 -235
View File
@@ -1,59 +1,62 @@
/* 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";
import { expectTypeOf } from "expect-type";
import {
getPrimitiveUnionMatcher,
getPrimitiveUnionMatcherPartial,
getPrimitiveUnionMatcherPartialW,
getPrimitiveUnionMatcherW,
} from "./primitive.ts";
type CompletionTarget,
LspSession,
} from "#test-utils/lsp-completion.ts";
import { getMatcher, getMatcherW } from "./primitive.ts";
// ============================================================================
// API: getPrimitiveUnionMatcherW — ✔️ Exhaustive / ❌ ReturnsStrict
// API: getMatcher — ✔️ Exhaustive / ✔️ ReturnsStrict
// ============================================================================
test("getPrimitiveUnionMatcherW requires every literal key", () => {
test("getMatcher: exhaustive pattern infers one common return type", () => {
// Arrange
const factory = getPrimitiveUnionMatcherW<"a" | "b">();
const factory = getMatcher<"a" | "b">();
// Act
const matcher = factory({
a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">();
return 1 as const;
assert.equal(s, "a");
return 1;
},
b: (s) => {
expectTypeOf(s).toEqualTypeOf<"b">();
return "two" as const;
assert.equal(s, "b");
return 2;
},
});
// Assert
// ❌ ReturnsStrict: mixed handler returns widen to their union.
expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b") => 1 | "two">();
// A pattern missing a key must not satisfy the parameter type.
expectTypeOf<{
a: () => number;
}>().not.toExtend<Parameters<typeof factory>[0]>();
// ✔️ ReturnsStrict: R is the best common return type, not a widening union.
expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b") => number>();
// ❌ a value outside T must not be accepted by the matcher
expectTypeOf<"c">().not.toExtend<Parameters<typeof matcher>[0]>();
assert.equal(matcher("a"), 1);
assert.equal(matcher("b"), "two");
assert.equal(matcher("b"), 2);
});
test("getPrimitiveUnionMatcherW dispatches on numeric literal keys", () => {
test("getMatcher: dispatches on numeric literal keys", () => {
// Arrange
const factory = getPrimitiveUnionMatcherW<1 | 2>();
const factory = getMatcher<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;
},
});
@@ -64,26 +67,254 @@ test("getPrimitiveUnionMatcherW dispatches on numeric literal keys", () => {
assert.equal(matcher(2), 20);
});
test("getPrimitiveUnionMatcherW dispatches on mixed string and numeric keys", () => {
test("getMatcher: dispatches on mixed string and numeric keys", () => {
// Arrange
const factory = getPrimitiveUnionMatcherW<"a" | "b" | 1 | 2>();
const factory = getMatcher<"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;
},
});
// Assert
expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b" | 1 | 2) => number>();
assert.equal(matcher("a"), 1);
assert.equal(matcher("b"), 2);
assert.equal(matcher(1), 10);
assert.equal(matcher(2), 20);
});
test("getMatcher: dispatches on boolean literals", () => {
// Arrange
const factory = getMatcher<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("getMatcher: dispatches on null and undefined", () => {
// Arrange
const factory = getMatcher<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
// ============================================================================
test("getMatcher: a `_` fallback receives the unhandled keys", () => {
// Arrange
const factory = getMatcher<"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;
},
);
// Assert
expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b" | "c") => 1 | 2>();
// ❌ a value outside T must not be accepted by the matcher
expectTypeOf<"d">().not.toExtend<Parameters<typeof matcher>[0]>();
assert.equal(matcher("a"), 1);
assert.equal(matcher("b"), 2);
assert.equal(matcher("c"), 2);
});
test("getMatcher: a `_` fallback routes mixed string and numeric gaps", () => {
// Arrange
const factory = getMatcher<"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";
},
);
// Assert
expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b" | 1 | 2) => string>();
assert.equal(matcher("a"), "A");
assert.equal(matcher("b"), "fallback");
assert.equal(matcher(1), "one");
assert.equal(matcher(2), "fallback");
});
test("getMatcher: a `_` fallback receives unhandled boolean and nullish keys", () => {
// Arrange
const factory = getMatcher<"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
// ============================================================================
test("getMatcherW: exhaustive pattern widens to the union of handler returns", () => {
// Arrange
const factory = getMatcherW<"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;
},
});
// Assert
// ❌ ReturnsStrict: mixed handler returns widen to their union.
expectTypeOf(matcher).toEqualTypeOf<(shape: "x" | "y") => 1 | "two">();
// ❌ a value outside T must not be accepted by the matcher
expectTypeOf<"z">().not.toExtend<Parameters<typeof matcher>[0]>();
assert.equal(matcher("x"), 1);
assert.equal(matcher("y"), "two");
});
test("getMatcherW: dispatches on mixed string and numeric keys", () => {
// Arrange
const factory = getMatcherW<"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;
},
});
@@ -98,260 +329,428 @@ test("getPrimitiveUnionMatcherW dispatches on mixed string and numeric keys", ()
assert.equal(matcher(2), 20);
});
// ============================================================================
// API: getPrimitiveUnionMatcher — ✔️ Exhaustive / ✔️ ReturnsStrict
// ============================================================================
test("getPrimitiveUnionMatcher infers a single return type shared by all handlers", () => {
test("getMatcherW: exhaustive boolean and nullish widen to the union", () => {
// Arrange
const factory = getPrimitiveUnionMatcher<"a" | "b">();
const factory = getMatcherW<boolean | null | undefined>();
// Act
const matcher = factory({
a: (s): number => {
expectTypeOf(s).toEqualTypeOf<"a">();
return 1;
true: (s) => {
expectTypeOf(s).toEqualTypeOf<true>();
assert.equal(s, true);
return "yes" as const;
},
b: (s): 1 | 2 => {
expectTypeOf(s).toEqualTypeOf<"b">();
return 2;
false: (s) => {
expectTypeOf(s).toEqualTypeOf<false>();
assert.equal(s, false);
return "no" as const;
},
});
// Assert
// ✔️ ReturnsStrict: R is the best common return type, not a widening union.
expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b") => number>();
assert.equal(matcher("a"), 1);
assert.equal(matcher("b"), 2);
});
test("getPrimitiveUnionMatcher handlers receive the matched literal", () => {
// Arrange
const factory = getPrimitiveUnionMatcher<"on" | "off">();
// Act
const matcher = factory({
on: (s) => {
expectTypeOf(s).toEqualTypeOf<"on">();
return `handler ${s}`;
null: (s) => {
expectTypeOf(s).toEqualTypeOf<null>();
assert.equal(s, null);
return 0 as const;
},
off: (s) => {
expectTypeOf(s).toEqualTypeOf<"off">();
return `handler ${s}`;
},
});
// Assert
expectTypeOf(matcher).toEqualTypeOf<(shape: "on" | "off") => string>();
assert.equal(matcher("on"), "handler on");
assert.equal(matcher("off"), "handler off");
});
test("getPrimitiveUnionMatcher infers one return type across mixed string and numeric keys", () => {
// Arrange
const factory = getPrimitiveUnionMatcher<"a" | "b" | 1 | 2>();
// Act
const matcher = factory({
a: (s): number => {
expectTypeOf(s).toEqualTypeOf<"a">();
return 1;
},
b: (s): 1 | 2 => {
expectTypeOf(s).toEqualTypeOf<"b">();
return 2;
},
1: (n): number => {
expectTypeOf(n).toEqualTypeOf<1>();
return 10;
},
2: (n): number => {
expectTypeOf(n).toEqualTypeOf<2>();
return 20;
},
});
// Assert
// ✔️ ReturnsStrict: R is the best common return type, not a widening union.
expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b" | 1 | 2) => number>();
assert.equal(matcher("a"), 1);
assert.equal(matcher("b"), 2);
assert.equal(matcher(1), 10);
assert.equal(matcher(2), 20);
});
// ============================================================================
// API: getPrimitiveUnionMatcherPartial — ❌ Exhaustive / ✔️ ReturnsStrict
// ============================================================================
test("getPrimitiveUnionMatcherPartial routes shapes without a handler to _", () => {
// Arrange
const factory = getPrimitiveUnionMatcherPartial<"a" | "b" | "c">();
// Two keys, so `{ a }` lacks only the `_` fallback, nothing else.
const sparseFactory = getPrimitiveUnionMatcherPartial<"a" | "b">();
// Act
const matcher = factory({
a: (s): 1 | 2 => {
expectTypeOf(s).toEqualTypeOf<"a">();
return 1;
},
_: (s): 1 | 2 => {
// The fallback sees the whole union, not a single literal.
expectTypeOf(s).toEqualTypeOf<"a" | "b" | "c">();
return 2;
},
});
// Assert
expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b" | "c") => 1 | 2>();
// ❌ Exhaustive: gaps are allowed, but only with a `_` fallback.
expectTypeOf<{
a: () => number;
}>().not.toExtend<Parameters<typeof sparseFactory>[0]>();
assert.equal(matcher("a"), 1);
assert.equal(matcher("b"), 2);
assert.equal(matcher("c"), 2);
});
test("getPrimitiveUnionMatcherPartial also accepts an exhaustive pattern", () => {
// Arrange
const factory = getPrimitiveUnionMatcherPartial<"a" | "b">();
// Act
const matcher = factory({
a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">();
return "A";
},
b: (s) => {
expectTypeOf(s).toEqualTypeOf<"b">();
return "B";
},
});
// Assert
expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b") => string>();
assert.equal(matcher("a"), "A");
assert.equal(matcher("b"), "B");
});
test("getPrimitiveUnionMatcherPartial routes mixed string and numeric keys, gaps go to _", () => {
// Arrange
const factory = getPrimitiveUnionMatcherPartial<"a" | "b" | 1 | 2>();
// Act
const matcher = factory({
a: (s): string => {
expectTypeOf(s).toEqualTypeOf<"a">();
return "A";
},
1: (n): string => {
expectTypeOf(n).toEqualTypeOf<1>();
return "one";
},
_: (s): string => {
// The fallback sees the whole union, not a single literal.
expectTypeOf(s).toEqualTypeOf<"a" | "b" | 1 | 2>();
return "fallback";
},
});
// Assert
expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b" | 1 | 2) => string>();
assert.equal(matcher("a"), "A");
assert.equal(matcher("b"), "fallback");
assert.equal(matcher(1), "one");
assert.equal(matcher(2), "fallback");
});
// ============================================================================
// API: getPrimitiveUnionMatcherPartialW — ❌ Exhaustive / ❌ ReturnsStrict
// ============================================================================
test("getPrimitiveUnionMatcherPartialW allows gaps and widens to the union of handler returns", () => {
// Arrange
const factory = getPrimitiveUnionMatcherPartialW<"x" | "y" | "z">();
// Two keys, so `{ x }` lacks only the `_` fallback, nothing else.
const sparseFactory = getPrimitiveUnionMatcherPartialW<"x" | "y">();
// Act
const matcher = factory({
x: (s) => {
expectTypeOf(s).toEqualTypeOf<"x">();
undefined: (s) => {
expectTypeOf(s).toEqualTypeOf<undefined>();
assert.equal(s, undefined);
return 1 as const;
},
_: (s) => {
// The fallback sees the whole union, not a single literal.
expectTypeOf(s).toEqualTypeOf<"x" | "y" | "z">();
return "fallback" as const;
},
});
// Assert
expectTypeOf(matcher).toEqualTypeOf<
(shape: "x" | "y" | "z") => 1 | "fallback"
(shape: boolean | null | undefined) => "yes" | "no" | 0 | 1
>();
// ❌ Exhaustive: gaps are allowed, but only with a `_` fallback.
expectTypeOf<{
x: () => number;
}>().not.toExtend<Parameters<typeof sparseFactory>[0]>();
assert.equal(matcher("x"), 1);
assert.equal(matcher("y"), "fallback");
assert.equal(matcher("z"), "fallback");
assert.equal(matcher(true), "yes");
assert.equal(matcher(false), "no");
assert.equal(matcher(null), 0);
assert.equal(matcher(undefined), 1);
});
test("getPrimitiveUnionMatcherPartialW widens mixed string and numeric key returns to their union", () => {
// ============================================================================
// API: getMatcherW — ❌ Exhaustive (fallback) / ❌ ReturnsStrict
// ============================================================================
test("getMatcherW: a `_` fallback widens gaps into the union", () => {
// Arrange
const factory = getPrimitiveUnionMatcherPartialW<"a" | "b" | 1 | 2>();
const factory = getMatcherW<"x" | "y" | "z">();
// Act
const matcher = factory({
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">();
// 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 | "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"), "y");
assert.equal(matcher("z"), "z");
});
test("getMatcherW: a `_` fallback widens mixed string and numeric returns", () => {
// Arrange
const factory = getMatcherW<"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) => {
// The fallback sees the whole union, not a single literal.
expectTypeOf(s).toEqualTypeOf<"a" | "b" | 1 | 2>();
return "fallback" as const;
},
});
(s) => {
expectTypeOf(s).toEqualTypeOf<"b" | 2>();
// 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);
});
test("getPrimitiveUnionMatcherPartialW also accepts an exhaustive pattern", () => {
// ============================================================================
// Factory contracts — calls that must not compile
// ============================================================================
test("getMatcher factory rejects patterns outside its contract", () => {
// Arrange
const factory = getPrimitiveUnionMatcherPartialW<"x" | "y">();
const factory = getMatcher<"a" | "b">();
// Act / Assert — the calls below must not compile.
// `Parameters<typeof factory>[0]` resolves only the *last* overload, so it
// is not a sound "rejected" oracle (an accepted partial pattern does not
// extend it either); the call sites are the oracle instead.
factory({
a: () => 1,
b: () => 2,
// @ts-expect-error `c` is not part of the universe `"a" | "b"`
c: () => 3,
});
// @ts-expect-error a gap without a `_` fallback is not exhaustive
factory({ a: () => 1 });
// @ts-expect-error a `string` fallback does not fit the `number` handler
factory({ a: () => 1 }, () => "x");
// @ts-expect-error a fallback is redundant once the map covers the universe
factory({ a: () => 1, b: () => 2 }, () => 0);
});
test("getMatcher factory rejects non-exhaustive boolean and nullish maps", () => {
// Arrange
const factory = getMatcher<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("getMatcher: an open universe keeps the fallback's remainder open", () => {
// Arrange
const factory = getMatcher<string>();
// Act
const matcher = factory({
x: (s) => {
expectTypeOf(s).toEqualTypeOf<"x">();
return 1 as const;
},
y: (s) => {
expectTypeOf(s).toEqualTypeOf<"y">();
return "two" as const;
},
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;
});
// Assert
// ❌ ReturnsStrict: mixed handler returns widen to their union.
expectTypeOf(matcher).toEqualTypeOf<(shape: "x" | "y") => 1 | "two">();
assert.equal(matcher("x"), 1);
assert.equal(matcher("y"), "two");
expectTypeOf(matcher).toEqualTypeOf<(shape: string) => number>();
assert.equal(matcher("a"), 1);
assert.equal(matcher("b"), 2);
});
test("getMatcherW factory rejects patterns outside its contract", () => {
// Arrange
const factory = getMatcherW<"x" | "y">();
// Act / Assert — the calls below must not compile
factory({
x: () => 1 as const,
y: () => 2 as const,
// @ts-expect-error `z` is not part of the universe `"x" | "y"`
z: () => 3 as const,
});
// @ts-expect-error a gap without a `_` fallback is not exhaustive
factory({ x: () => 1 as const });
// @ts-expect-error a fallback is redundant once the map covers the universe
factory({ x: () => 1 as const, y: () => 2 as const }, () => 0);
});
// ============================================================================
// Dispatch — runtime behavior
// ============================================================================
test("getMatcher: 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, (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 = getMatcher<string>()(handlers);
// Act / Assert
assert.equal(matcher("a"), 1);
assert.throws(() => matcher("b"), /Unhandled shape: b/);
});
test("getMatcher: 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 = getMatcher<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
// ============================================================================
// Completion is a contextual-type property that the type system cannot observe,
// so the cases below read the popup from the repo's language server (via the
// test helper) rather than pairing `expectTypeOf` with `assert` — see
// development/testing.md § Autocomplete. They probe the *matcher's* overloads,
// which is why they live with the matcher and not with the helper.
const REPO_ROOT = path.resolve(import.meta.dirname, "..");
const UNIVERSE = `"a" | "b" | "c"`;
// One session per probe: the tests share no language-server state (open
// documents, project membership), so they pass in any order.
interface LabelsProbe {
readonly name: string;
readonly factory: "getMatcher" | "getMatcherW";
readonly body: string;
readonly universe?: string;
readonly tail?: string;
}
const labelsFor = ({
name,
factory,
body,
universe = UNIVERSE,
tail = "",
}: LabelsProbe): Promise<readonly string[]> => {
const session = new LspSession(REPO_ROOT);
const target: CompletionTarget = {
file: `src/__autocomplete_${name}.ts`,
source: [
`import { ${factory} } from "./index.ts";`,
`const m = ${factory}<${universe}>()({`,
body,
`}${tail});`,
"",
].join("\n"),
};
return session
.completionLabelsAt(target)
.then((result) => result.labels)
.finally(() => session.close());
};
test("autocomplete: an exhaustive pattern requires the universe", () => {
// Arrange
const name = "getMatcher_fresh";
// Act
const labels = labelsFor({
name,
factory: "getMatcher",
body: " /*COMPLETE*/",
});
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["a", "b", "c"]);
});
});
test("autocomplete: handled keys drop out of the popup", () => {
// Arrange
const name = "getMatcher_after_key";
// Act
const labels = labelsFor({
name,
factory: "getMatcher",
body: " a: () => 1,\n /*COMPLETE*/",
});
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["b", "c"]);
});
});
test("autocomplete: a fallback makes the remaining keys optional", () => {
// Arrange
const name = "getMatcher_with_fallback";
// Act
const labels = labelsFor({
name,
factory: "getMatcher",
body: " /*COMPLETE*/",
tail: ", () => 0",
});
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["a?", "b?", "c?"]);
});
});
test("autocomplete: with a fallback, handled keys stay optional", () => {
// Arrange
const name = "getMatcher_with_fallback_after_key";
// Act
const labels = labelsFor({
name,
factory: "getMatcher",
body: " a: () => 1,\n /*COMPLETE*/",
tail: ", () => 0",
});
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["b?", "c?"]);
});
});
test("autocomplete: `getMatcherW` offers the same popup as `getMatcher`", () => {
// Arrange
const name = "getMatcherW_fresh";
// Act
const labels = labelsFor({
name,
factory: "getMatcherW",
body: " /*COMPLETE*/",
});
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["a", "b", "c"]);
});
});
test("autocomplete: `getMatcherW` also offers optional keys with a fallback", () => {
// Arrange
const name = "getMatcherW_with_fallback";
// Act
const labels = labelsFor({
name,
factory: "getMatcherW",
body: " /*COMPLETE*/",
tail: ", () => 0",
});
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["a?", "b?", "c?"]);
});
});
test("autocomplete: boolean and nullish keys are offered by name", () => {
// Arrange
const name = "getMatcher_boolean_nullish";
// Act
const labels = labelsFor({
name,
factory: "getMatcher",
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 = "getMatcher_boolean_after_key";
// Act
const labels = labelsFor({
name,
factory: "getMatcher",
universe: "boolean | null | undefined",
body: " true: () => 1,\n /*COMPLETE*/",
});
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["false", "null", "undefined"]);
});
});
+117 -60
View File
@@ -1,71 +1,128 @@
import type { Simplify, ValueOf } from "type-fest";
import type { Exact } from "type-fest";
type UnaryFn<T, R> = (shape: T) => R;
import type {
HandlerMap,
PatternReturns,
RedundantFallback,
UnaryFn,
} from "./matcher-shared.ts";
// ============================================================================
// ✔️ Exhaustive
// ❌ ReturnsStrict
// ============================================================================
type PatternPrimitiveUnion<R, T extends string | number> = {
[K in T]: UnaryFn<K, R>;
// The primitive universe a matcher can discriminate. `boolean` is admitted as
// the pair `true | false`; see README § Caveats for the unsupported members.
type Matchable = string | number | boolean | null | undefined;
// `boolean`, `null` and `undefined` cannot be property keys, so a mapped type
// over the universe keys each non-key member by its stringification. `Param`
// inverts that projection, so a handler callback still receives the *real*
// member (`true`, not `"true"`) — see README § Caveats for the limits.
type PatternKey<T> = T extends boolean
? T extends true
? "true"
: "false"
: T extends null
? "null"
: T extends undefined
? "undefined"
: T;
type PatternParam<K> = K extends "true"
? true
: K extends "false"
? false
: K extends "null"
? null
: K extends "undefined"
? undefined
: K;
type Handlers<T extends Matchable, R> = {
[K in PatternKey<T>]: UnaryFn<PatternParam<K>, R>;
};
type PatternReturns<
P extends Record<string | number, UnaryFn<never, unknown>>,
> = ReturnType<ValueOf<P>>;
// 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
// fixes a property's contextual type before it infers its sibling keys. A later
// argument, by contrast, is contextually typed from inference on an earlier
// one, so the split is what makes the remainder expressible at all.
// See development/library.md.
type Fallback<T extends Matchable, Handled, R> = UnaryFn<
Exclude<T, PatternParam<keyof Handled>>,
R
>;
export const getPrimitiveUnionMatcherW: <T extends string | number>() => <
P extends PatternPrimitiveUnion<unknown, T>,
>(
pattern: Simplify<P>,
) => UnaryFn<T, PatternReturns<P>> = () => (pattern) => (shape) =>
// Rewrite not to use any is possible, was evaluated and solutions were
// more complex than the current solution.
// A fallback is redundant once the handler map covers `T`. The guard is folded
// into `Handled`'s own (self-referential) constraint so it is checked *after*
// inference; a conditional in the fallback's parameter type is evaluated while
// `Handled` is still its constraint and would reject context-sensitive partial
// maps. That placement also fixes where the diagnostic lands: the constraint
// failure is reported on the argument that inferred `Handled` (the handler
// map), so the required property is spelled as the message instead of relying
// on its position. See development/library.md.
type MustBePartial<T extends Matchable, Handled> =
PatternKey<T> extends keyof Handled ? RedundantFallback : unknown;
// oxlint-disable-next-line typescript/no-explicit-any typescript/no-unsafe-type-assertion
(pattern[shape] as any)(shape);
// 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`.
// ============================================================================
// ✔️ Exhaustive
// ✔️ ReturnsStrict
// ============================================================================
export const getPrimitiveUnionMatcher: <T extends string | number>() => <R>(
pattern: Simplify<PatternPrimitiveUnion<R, T>>,
) => UnaryFn<T, R> = getPrimitiveUnionMatcherW;
// ============================================================================
// ❌ Exhaustive
// ✔️ ReturnsStrict
// ============================================================================
type PatternPrimitiveUnionPartial<R, T extends string | number> =
| PatternPrimitiveUnion<R, T>
| (Partial<PatternPrimitiveUnion<R, T>> & {
_: UnaryFn<T, R>;
});
export const getPrimitiveUnionMatcherPartial: <T extends string | number>() => <
// 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 Matchable> {
<R>(handlers: Handlers<T, R>): UnaryFn<T, R>;
<
R,
Handled extends Exact<Partial<Handlers<T, R>>, Handled> &
MustBePartial<T, Handled>,
>(
pattern: Simplify<PatternPrimitiveUnionPartial<R, T>>,
) => UnaryFn<T, R> = () => (pattern) => (shape) =>
// Rewrite not to use any is possible, was evaluated and solutions were
// more complex than the current solution.
handlers: Handled & Partial<Handlers<T, R>>,
fallback: Fallback<T, Handled, R>,
): UnaryFn<T, R>;
}
// oxlint-enable typescript/unified-signatures
// oxlint-disable-next-line typescript/no-explicit-any typescript/no-unsafe-type-assertion
(pattern[shape] ?? (pattern as any)["_"])(shape);
// 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 Matchable> {
<P extends Exact<Handlers<T, unknown>, P>>(
handlers: P,
): UnaryFn<T, PatternReturns<P>>;
<
R,
Handled extends Exact<Partial<Handlers<T, unknown>>, Handled> &
MustBePartial<T, Handled>,
>(
handlers: Handled,
fallback: Fallback<T, Handled, R>,
): UnaryFn<T, PatternReturns<Handled> | R>;
}
// oxlint-enable typescript/unified-signatures
// ============================================================================
// ❌ Exhaustive
// ❌ ReturnsStrict
// ============================================================================
export const getPrimitiveUnionMatcherPartialW: <
T extends string | number,
>() => <P extends PatternPrimitiveUnionPartial<unknown, T>>(
// `Simplify<P>` is the inference hook: callers infer `P` from the argument.
// the second half pins the impl parameter's `R` to `PatternReturns<P>`.
// that makes the `= getPrimitiveUnionMatcherPartial` assignment type-check.
// neither half works alone.
// without the witness the union's `_` arm demands `_ ∈ keyof P`.
// without `Simplify<P>` the parameter types do not compare.
pattern: Simplify<P> & PatternPrimitiveUnionPartial<PatternReturns<P>, T>,
) => UnaryFn<T, PatternReturns<P>> = getPrimitiveUnionMatcherPartial;
const dispatch =
(handlers: HandlerMap, fallback?: UnaryFn<never, unknown>) =>
(shape: Matchable): unknown =>
// `handlers[true]` already coerces to the `"true"` property at runtime,
// identical to `handlers[String(shape)]`, so indexing with `shape`
// directly is sound: `shape` is a facade-checked universe member and
// `PatternKey` only ever produces valid property keys. The assertion is
// needed solely because TypeScript forbids indexing with
// `boolean`/`null`/`undefined` (TS2538); it buys the number fast path.
(
handlers[
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
shape as string | number
] ??
fallback ??
(() => {
throw new Error(`Unhandled shape: ${String(shape)}`);
})
)(
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
shape as never,
);
export const getMatcher = <T extends Matchable>(): MatcherStrict<T> => dispatch;
export const getMatcherW = <T extends Matchable>(): MatcherWidening<T> =>
dispatch;
+452
View File
@@ -0,0 +1,452 @@
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);
});
// ============================================================================
// 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("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;
}
const labelsFor = ({
name,
factory,
body,
tail = "",
}: LabelsProbe): Promise<readonly string[]> => {
const session = new LspSession(REPO_ROOT);
const target: CompletionTarget = {
file: `src/__autocomplete_${name}.ts`,
source: [
`import { ${factory} } from "./index.ts";`,
SHAPE_SOURCE,
`const m = ${factory}<Shape>()("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?"]);
});
});
// `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"'));
});
});
+124
View File
@@ -0,0 +1,124 @@
import type { Exact, UnknownRecord } from "type-fest";
import type {
HandlerMap,
PatternReturns,
RedundantFallback,
UnaryFn,
} from "./matcher-shared.ts";
// A tagged union is discriminated by one property whose values are the tags.
// Only `string` and `number` tags can key a handler map: `symbol` has no
// literal syntax to write a handler under, and `bigint` is not a property key.
type Tag = string | number;
// 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], Tag>;
// 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 Tag ? 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.
type MapTaggedUnion<T extends object, K extends keyof T> = {
[V in Tags<T, K>]: Extract<T, Record<K, V>>;
};
type Handlers<T extends object, K extends keyof T, R> = {
[V in Tags<T, K>]: UnaryFn<MapTaggedUnion<T, K>[V], R>;
};
// The members `Handled` covers. Mapping over `Tags` 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 matcher's `Exclude<T, PatternParam<keyof Handled>>`.
type HandledMembers<T extends object, K extends keyof T, Handled> = {
[V in Tags<T, K>]: V extends keyof Handled
? MapTaggedUnion<T, K>[V]
: never;
}[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 matcher for why a conditional in the fallback's
// parameter is evaluated too early.
type MustBePartial<T extends object, K extends keyof T, Handled> =
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>;
}
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 `string | number` tags,
// 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>() =>
<K extends Discriminated<T>>(k: K): TaggedUnionMatcherStrict<T, K> =>
dispatch(k);
export const getTaggedUnionMatcherW =
<T extends object>() =>
<K extends Discriminated<T>>(k: K): TaggedUnionMatcherWidening<T, K> =>
dispatch(k);
+147
View File
@@ -0,0 +1,147 @@
import { strict as assert } from "node:assert";
import path from "node:path";
import { test } from "node:test";
import { expectTypeOf } from "expect-type";
import {
type CompletionResult,
type CompletionTarget,
LspSession,
} from "#test-utils/lsp-completion.ts";
const REPO_ROOT = path.resolve(import.meta.dirname, "../../..");
// The `#test-utils/*` self-reference (package.json#imports) is the one route
// scattered test files take to reach test helpers; this pins that it resolves
// and types without starting a language server
// (development/testing.md § Test helpers).
test("test-helper home: `#test-utils/…` resolves to the helper and types it", () => {
// Arrange
const target: CompletionTarget = {
file: "src/util/__tests__/lsp-completion.ts",
source: "",
};
// Assert — no session is constructed: the constructor spawns `tsc --lsp`,
// so only the resolved values and their types are checked here.
expectTypeOf(LspSession).toBeConstructibleWith("repo-root");
expectTypeOf<CompletionResult>().toMatchTypeOf<{
labels: readonly string[];
}>();
assert.equal(typeof LspSession, "function");
assert.equal(target.file, "src/util/__tests__/lsp-completion.ts");
});
// The helper drives a language server, so its contract is asserted against the
// server. Every document below is probed from memory and carries its
// contextual type inline, so the helper is tested without the library's code.
const probe = (source: string, marker?: string): Promise<CompletionResult> => {
const session = new LspSession(REPO_ROOT);
const target: CompletionTarget =
marker === undefined
? { file: "src/__probe.ts", source }
: { file: "src/__probe.ts", source, marker };
return session.completionLabelsAt(target).finally(() => session.close());
};
const HANDLERS = [
"type Handlers = { a: () => number; b: () => number; _?: () => number };",
"declare const apply: (handlers: Handlers) => Handlers;",
];
test("helper: a fresh object literal completes with its contextual keys", () => {
// Arrange
const source = [
...HANDLERS,
"const done = apply({",
" /*COMPLETE*/",
"});",
"export { done };",
].join("\n");
// Act
const probed = probe(source);
// Assert — the position is the marker's, which the helper strips
expectTypeOf(probed).toEqualTypeOf<Promise<CompletionResult>>();
return probed.then((result) => {
assert.deepEqual(result.position, { line: 3, character: 4 });
assert.deepEqual([...result.labels], ["_?", "a", "b"]);
});
});
test("helper: a handled key drops out of the popup", () => {
// Arrange
const source = [
...HANDLERS,
"const done = apply({",
" b: () => 1,",
" /*COMPLETE*/",
"});",
"export { done };",
].join("\n");
// Act
const probed = probe(source);
// Assert
expectTypeOf<CompletionResult["labels"]>().toEqualTypeOf<
readonly string[]
>();
return probed.then((result) => {
assert.deepEqual([...result.labels], ["_?", "a"]);
});
});
test("helper: a custom marker is located at the line start", () => {
// Arrange
const source = [
"type Handlers = { a: () => number; _?: () => number };",
"declare const apply: (handlers: Handlers) => Handlers;",
"const done = apply({",
"@@@",
"});",
"export { done };",
].join("\n");
// Act
const probed = probe(source, "@@@");
// Assert
expectTypeOf(probed).resolves.toEqualTypeOf<CompletionResult>();
return probed.then((result) => {
assert.deepEqual(result.position, { line: 3, character: 0 });
assert.deepEqual([...result.labels], ["_?", "a"]);
});
});
test("helper: labels are read from the server, not from a pattern literal", () => {
// Arrange
const source = [
'const text = "x";',
"const upper = text./*COMPLETE*/;",
"export { upper };",
].join("\n");
// Act
const probed = probe(source);
// Assert
expectTypeOf(probed).resolves.toHaveProperty("labels");
return probed.then((result) => {
assert.ok(result.labels.includes("toUpperCase"));
});
});
test("helper: a source without the marker rejects", () => {
// Arrange
const source = "const text = 1;\nexport { text };\n";
// Act
const probed = probe(source);
// Assert
expectTypeOf(probed).resolves.toEqualTypeOf<CompletionResult>();
return assert.rejects(probed, /marker not found/);
});
+254
View File
@@ -0,0 +1,254 @@
// oxlint-disable no-magic-numbers unicorn/no-null - tolerable here, this is a helper
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { pathToFileURL } from "node:url";
import {
type CompletionItem,
type CompletionList,
CompletionRequest,
ConfigurationRequest,
createMessageConnection,
DidOpenTextDocumentNotification,
InitializedNotification,
InitializeRequest,
type MessageConnection,
ShutdownRequest,
StreamMessageReader,
StreamMessageWriter,
} from "vscode-languageserver-protocol/node";
/**
* Print the completion labels TypeScript's language server offers at a marker
* inside a source file.
*
* Usage: node --strip-types src/util/__tests__/lsp-completion.ts <file> [<marker>]
*
* The marker (default `DEFAULT_MARKER`, a `COMPLETE` block comment) is stripped
* from the source before it is sent; the request position is where the marker
* stood. Point it at a scratch file whose expected type is a matcher pattern to
* inspect the popup.
*
* `LspSession` exposes the same probe to the test suite, so completion is
* asserted against the server rather than the type system.
*
* Why a script and not a type assertion: completion is a contextual-type
* property that `expect-type` cannot observe, and `Parameters<…>` resolves only
* a single overload. The server is the only ground truth. See
* development/testing.md § Autocomplete.
*/
const DEFAULT_MARKER = "/*COMPLETE*/";
const ARGV_PREFIX_LENGTH = 2;
const EXIT_DELAY_MS = 200;
const FAILURE_EXIT_CODE = 1;
const USAGE = "usage: lsp-completion.ts <file> [<marker>]";
export interface Position {
readonly line: number;
readonly character: number;
}
/** A document to probe: `source` carries `marker`, which is stripped. */
export interface CompletionTarget {
/** Path relative to the session's repo root; drives URI and resolution. */
readonly file: string;
readonly source: string;
readonly marker?: string;
}
export interface CompletionResult {
readonly position: Position;
readonly labels: readonly string[];
}
const completionLabels = (
result: CompletionItem[] | CompletionList | null,
): readonly string[] => {
if (result === null) {
return [];
}
const items = Array.isArray(result) ? result : result.items;
return items
.map((item) => item.label)
.toSorted((left, right) => left.localeCompare(right));
};
const markerPosition = (source: string, marker: string): Position => {
const index = source.indexOf(marker);
if (index === -1) {
throw new Error(`marker not found: ${marker}`);
}
const lines = source.slice(0, index).split("\n");
const lineIndex = lines.length - 1;
const line = lines[lineIndex];
return { line: lineIndex, character: line === undefined ? 0 : line.length };
};
/**
* One language server, initialized on first use, shared across probes. Callers
* own the lifecycle and must `close()` it.
*/
export class LspSession {
readonly #child: ChildProcessWithoutNullStreams;
readonly #connection: MessageConnection;
readonly #repoRoot: string;
#ready: Promise<void> | undefined;
public constructor(repoRoot: string) {
this.#repoRoot = repoRoot;
this.#child = spawn(
path.join(repoRoot, "node_modules", ".bin", "tsc"),
["--lsp", "--stdio"],
{ cwd: repoRoot },
);
this.#child.stderr.on("data", (chunk: Buffer) => {
process.stderr.write(chunk);
});
this.#connection = createMessageConnection(
new StreamMessageReader(this.#child.stdout),
new StreamMessageWriter(this.#child.stdin),
);
// Server -> client requests the server waits on: answer so it proceeds.
// `workspace/configuration` wants one reply per requested item; a
// catch-all covers the rest (e.g. `client/registerCapability`).
this.#connection.onRequest(ConfigurationRequest.type, ({ items }) =>
items.map(() => null),
);
this.#connection.onRequest(() => null);
this.#connection.listen();
}
public completionLabelsAt(
target: CompletionTarget,
): Promise<CompletionResult> {
return this.#ensureInitialized()
.then(() => this.#open(target))
.then(({ uri, position }) =>
this.#connection
.sendRequest(CompletionRequest.type, {
textDocument: { uri },
position,
context: { triggerKind: 1 },
})
.then((result) => ({
position,
labels: completionLabels(result),
})),
);
}
/** `shutdown` + close stdin, then kill the server; safe after a failed probe. */
public close(): Promise<void> {
return (
this.#connection
.sendRequest(ShutdownRequest.type)
// The TS 7 Go server logs a bare `context canceled` to stderr
// when it handles `exit`; EOF on stdin shuts it down cleanly
// (exit 0, no output) instead.
.then(() => {
this.#child.stdin.end();
})
.then(() => delay(EXIT_DELAY_MS))
.finally(() => {
this.#connection.dispose();
this.#child.kill();
})
);
}
#ensureInitialized(): Promise<void> {
this.#ready ??= this.#initialize();
return this.#ready;
}
#initialize(): Promise<void> {
return this.#connection
.sendRequest(InitializeRequest.type, {
processId: process.pid,
rootUri: pathToFileURL(this.#repoRoot).href,
workspaceFolders: [
{ uri: pathToFileURL(this.#repoRoot).href, name: "repo" },
],
capabilities: {
textDocument: {
completion: {
completionItem: { snippetSupport: false },
},
publishDiagnostics: {},
},
},
})
.then(() =>
this.#connection.sendNotification(
InitializedNotification.type,
{},
),
);
}
#open(target: CompletionTarget): Promise<{
readonly uri: string;
readonly position: Position;
}> {
const absolute = path.resolve(this.#repoRoot, target.file);
const marker = target.marker ?? DEFAULT_MARKER;
const position = markerPosition(target.source, marker);
const text = target.source.replace(marker, "");
const uri = pathToFileURL(absolute).href;
// The server handles `didOpen` in order before the completion request,
// so no settle delay is needed.
return this.#connection
.sendNotification(DidOpenTextDocumentNotification.type, {
textDocument: {
uri,
languageId: "typescript",
version: 1,
text,
},
})
.then(() => ({ uri, position }));
}
}
const main = (args: readonly string[]): Promise<void> => {
const [file, markerArgument] = args;
if (file === undefined) {
process.stderr.write(`${USAGE}\n`);
process.exitCode = FAILURE_EXIT_CODE;
return Promise.resolve();
}
const repoRoot = path.resolve(import.meta.dirname, "../../..");
const absolute = path.resolve(repoRoot, file);
const source = fs.readFileSync(absolute, "utf8");
const session = new LspSession(repoRoot);
return session
.completionLabelsAt({
file,
source,
marker: markerArgument ?? DEFAULT_MARKER,
})
.then(({ position, labels }) => {
process.stdout.write(
`\n[${file}] completions @ ${position.line}:${position.character}:\n${labels.join(", ")}\n`,
);
})
.finally(() => session.close());
};
const isEntryPoint = (): boolean => {
const [entry] = process.argv.slice(1, 2);
return entry !== undefined && import.meta.url === pathToFileURL(entry).href;
};
if (isEntryPoint()) {
main(process.argv.slice(ARGV_PREFIX_LENGTH)).catch((error: unknown) => {
process.stderr.write(
`${error instanceof Error ? error.message : String(error)}\n`,
);
process.exitCode = FAILURE_EXIT_CODE;
});
}