14 Commits
Author SHA1 Message Date
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
14 changed files with 491 additions and 74 deletions

No files matched your search

+3 -1
View File
@@ -34,7 +34,9 @@
"no-unused-expressions": "off", "no-unused-expressions": "off",
"no-empty-file": "off", "no-empty-file": "off",
"import/no-nodejs-modules": "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"
} }
}, },
{ {
+7 -6
View File
@@ -25,10 +25,11 @@ first-action facts. Do not restate evolving prose here — it will drift.
Don't silence the type system to force a green run. As an agent these are forbidden: Don't silence the type system to force a green run. As an agent these are forbidden:
- `// @ts-nocheck`, `// @ts-ignore`, `// @ts-expect-error` - `// @ts-nocheck`, `// @ts-ignore`, `// @ts-expect-error`
- `// oxlint-disable` / `// oxlint-disable-next-line` — the sole exception is the fixed file-level `typescript/no-floating-promises` header at the top of a `*.test.ts` file, spelled exactly as [CONTRIBUTING.md § Rules the tools don't enforce](./CONTRIBUTING.md#rules-the-tools-dont-enforce) prescribes - `// oxlint-disable` / `// oxlint-disable-next-line`
- editing `.oxlintrc.json` to silence a finding (e.g. turning `typescript/no-floating-promises` off)
- `as` casts used to push an expression through (type-aware oxlint already flags unsafe assertions) - `as` casts used to push an expression through (type-aware oxlint already flags unsafe assertions)
Fix the root cause with the type system instead — narrowing, generics, `satisfies`, conditional / mapped types, utility types (`NonNullable`, `Exclude`, …). TypeScript can express it; that's the intended tool. The `oxlint-disable`-location rule in [CONTRIBUTING.md § Rules the tools don't enforce](./CONTRIBUTING.md#rules-the-tools-dont-enforce) is a **human** last-resort convention (so a reviewer can spot a deliberate suppression) — it is not permission for you to add one, except the one fixed `*.test.ts` file-level header named there. If the types genuinely cannot express something, stop and surface the conflict (commit message / handover) rather than suppress it. Fix the root cause with the type system instead — narrowing, generics, `satisfies`, conditional / mapped types, utility types (`NonNullable`, `Exclude`, …). TypeScript can express it; that's the intended tool. Suppressions — a source `oxlint-disable` **or** a `.oxlintrc.json` entry — are a **human** last resort, not a tool for you. If the types genuinely cannot express something, stop and surface the conflict (commit message / handover) rather than suppress it.
The same applies to the checks themselves: **never `git commit --no-verify`** (or otherwise skip a pre-commit / pre-push hook). The checks are fast and offline, so a redundant run is fine — bypassing a hook to get green is the identical anti-pattern. If a commit already skipped a hook, redo it through one: `git reset --soft HEAD~1 && git commit -C <skipped-sha>`. 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 ### 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 ```md
## Handover — <branch> ## 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). 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. Follow [CONTRIBUTING.md § Testing discipline (type-driven)](./CONTRIBUTING.md#testing-discipline-type-driven) throughout.
In both cases, follow [CONTRIBUTING.md § Testing discipline (type-driven)](./CONTRIBUTING.md#testing-discipline-type-driven). Each subtask gets one or more commits.
## Read these ## Read these
+11 -2
View File
@@ -7,7 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
- move the `_` fallback to a second argument typed `Exclude<T, keyof handlers>`, so it receives only the unhandled keys, and infer handler returns from short, unannotated handlers ## [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 ## [0.3.0] - 2026-09-18
@@ -61,7 +68,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- basic setup - basic setup
[Unreleased]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.3.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.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.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.8]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.7...0.1.8
+7 -7
View File
@@ -66,7 +66,10 @@ before the implementation. The loop is **type → red → green → refactor**:
4. **Refactor** — with the type system and the tests as the safety net, then 4. **Refactor** — with the type system and the tests as the safety net, then
`npm run verify` as the definition-of-done gate. `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 The autocomplete tests (`src/util/__tests__/lsp-completion.test.ts` for the
helper, `src/primitive.test.ts` for the matcher's popup) are the helper, `src/primitive.test.ts` for the matcher's popup) are the
exception — exception —
@@ -155,12 +158,9 @@ reaches for by default:
[Script prefix convention](#script-prefix-convention). [Script prefix convention](#script-prefix-convention).
- **`oxlint-disable` directives live in source, not `.oxlintrc.json`.** The - **`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 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
[AGENTS.md § Never do](./AGENTS.md#never-do). The sole agent exception is the finding (e.g. `typescript/no-floating-promises`) — see
fixed file-level header at the top of a `*.test.ts` file, spelled exactly: [AGENTS.md § Never do](./AGENTS.md#never-do). (why:
`/* oxlint-disable typescript/no-floating-promises -- expectTypeOf() is a sync
type-assertion library that the type-aware linter misidentifies as a promise
*/` — any other suppression stays human-last-resort. (why:
[development/tooling.md](./development/tooling.md#oxlint-disable-directives-live-next-to-the-code)) [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.** - **Don't put slow / network / whole-project scans in `check` or pre-commit.**
Advisory scans are not correctness gates; they belong under `maintain:`. (why: Advisory scans are not correctness gates; they belong under `maintain:`. (why:
+13 -1
View File
@@ -9,7 +9,7 @@ ordinary objects whose `matches` method is a TypeScript type guard, so narrowing
composes the way any other guard does. It is deliberately not a regex engine and 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 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) 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 ## Requirements
@@ -23,6 +23,18 @@ for the design decisions and the known limitations.
Yet to be implemented 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 ## License
MIT © 2025 tmu. See [LICENSE](./LICENSE). MIT © 2025 tmu. See [LICENSE](./LICENSE).
+5 -4
View File
@@ -44,16 +44,17 @@ Matcher:
→ fold into `src/primitive.ts` / the public API; drop `src/prototype*.ts` → 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 ✔ `_` 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>` → 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 ✔ A fallback for an already-exhaustive handler map must be a compile error @medium @done
→ `(handlers, fallback)` with `handlers` covering all of `T` is accepted; the redundant fallback should be rejected (currying would allow the guard) → 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
Bugs: Bugs:
✔ TS 7 LSP server logs `context canceled` on stderr at shutdown @done ✔ 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 → `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 → close stdin after `shutdown` instead of sending `exit`; the server exits cleanly (code 0, no output), kill kept as a fallback
Enhancements: Enhancements:
☐ Allow boolean literals in primitive union patterns (e.g. `true: () => "yes"`) @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 ✔ 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)
Documentation: Documentation:
☐ Bring README.md back to its previous form — synopsis and examples restored, in the correct place ☐ Bring README.md back to its previous form — synopsis and examples restored, in the correct place
+37 -10
View File
@@ -31,8 +31,9 @@ Each factory is two overloads whose order is load-bearing:
1. `Handlers<T, R>` — the exhaustive form, and the contextual type of the 1. `Handlers<T, R>` — the exhaustive form, and the contextual type of the
handler-map popup; handler-map popup;
2. `Handled extends Exact<Partial<Handlers<T, R>>, Handled>` plus 2. `Handled extends Exact<Partial<Handlers<T, R>>, Handled>` intersected with
`Fallback<T, Handled, R>` — a partial handler map plus the fallback. `MustBePartial<T, Handled>`, plus `Fallback<T, Handled, R>` — a partial
handler map plus the fallback, rejected when the map already covers `T`.
#### Why #### Why
@@ -41,6 +42,14 @@ Each factory is two overloads whose order is load-bearing:
handler map can only see all of `T`, never `Exclude<T, keyof Handled>`. A later 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 argument is contextually typed from inference on an earlier one, so the split
is what makes the remainder expressible. 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 - **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 (`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. is optional (`a?, b?, c?`). A gap without a fallback is reported against #1.
@@ -57,9 +66,9 @@ Each factory is two overloads whose order is load-bearing:
- **Single-object `_`** (the former shape). `_` sees only all of `T`; the - **Single-object `_`** (the former shape). `_` sees only all of `T`; the
remainder is not expressible there, and an exhaustive map plus `_` was remainder is not expressible there, and an exhaustive map plus `_` was
accepted. accepted.
- **Curried handlers-first** — `(handlers)(fallback)`. `Handled` is fixed before - **Curried handlers-first** — `(handlers)(fallback)`. Rejected: two calls for
the second call, so a redundant-fallback guard would work. Rejected: two calls the common case. It is not needed for the redundant-fallback guard, which the
for the common case. F-bounded constraint already provides (see Why).
- **`this` / HKT self-reference.** `this` is post-construction (method bodies, - **`this` / HKT self-reference.** `this` is post-construction (method bodies,
return positions); a parameter's contextual type is pre-construction. return positions); a parameter's contextual type is pre-construction.
`keyof this` in an interface method is the interface, not the literal. `keyof this` in an interface method is the interface, not the literal.
@@ -76,14 +85,32 @@ Each factory is two overloads whose order is load-bearing:
#### Known issue #### Known issue
- A redundant fallback is accepted: when the handler map already covers `T`, the
fallback is still allowed. The guard would be
`Exclude<T, keyof Handled> extends never ? never : unknown`, but the
conditional is evaluated before `Handled` is inferred; currying is the only
encoding that fixes it (see Rejected).
- `PatternReturns` must be - `PatternReturns` must be
`ReturnType<Extract<ValueOf<P>, (...args: never[]) => unknown>>` so it survives `ReturnType<Extract<ValueOf<P>, (...args: never[]) => unknown>>` so it survives
the closed, partly-optional `P` constraints. the closed, partly-optional `P` constraints.
- `Parameters<typeof factory>[0]` resolves only the **last** overload, so it is - `Parameters<typeof factory>[0]` resolves only the **last** overload, so it is
not a sound "rejected" oracle for a factory. Factory-negative tests use 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). `@ts-expect-error` call sites (the test file only — the general ban stands).
## 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).
+38 -4
View File
@@ -37,6 +37,40 @@ in
build step, and the runner relies on the `.ts` import-extension convention (see build step, and the runner relies on the `.ts` import-extension convention (see
[tooling.md](./tooling.md#source-imports-use-ts-extensions)). [tooling.md](./tooling.md#source-imports-use-ts-extensions)).
## 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 ## AAA ordering
The rule is in The rule is in
@@ -167,8 +201,8 @@ the CLI is for manual inspection.
## Known issues ## Known issues
- The type-aware linter misidentifies `expectTypeOf()` as a floating promise, so - The type-aware linter misidentifies `expectTypeOf()` as a floating promise.
test files that use it (`src/primitive.test.ts`) carry a file-level It is a known false positive, so `typescript/no-floating-promises` is off for
`oxlint-disable typescript/no-floating-promises` with an explanatory comment. `**/*.test.ts` in the `.oxlintrc.json` override rather than repeated as a
It is a known false positive, not a rule worth disabling project-wide (see file-level header (see
[tooling.md § oxlint-disable directives live next to the code](./tooling.md#oxlint-disable-directives-live-next-to-the-code)). [tooling.md § oxlint-disable directives live next to the code](./tooling.md#oxlint-disable-directives-live-next-to-the-code)).
+19 -11
View File
@@ -106,26 +106,34 @@ Type-aware oxlint is enabled via `options.typeAware: true` in `.oxlintrc.json`
#### Decision (2026-09) #### Decision (2026-09)
A type-aware rule that false-positives is silenced with a source-level A type-aware rule that false-positives **at one site** is silenced with a
`oxlint-disable` directive (see `src/primitive.ts`, source-level `oxlint-disable` directive (see `src/primitive.ts`). A rule that is
`src/primitive.test.ts`), not by turning the rule off in `.oxlintrc.json`. 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 #### Why
- The disable sits next to the code it silences, visible to anyone reading the - A one-site disable sits next to the code it silences, visible to anyone
source. reading the source, and the rule stays on everywhere else.
- The rule stays on everywhere else, so only the mis-firing line is exempted. - 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 #### Rejected
- A project-wide disable in `.oxlintrc.json` for a false positive: it hides the - A project-wide disable in `.oxlintrc.json` for a one-site false positive: it
exemption from the reader of the affected code and switches the rule off hides the exemption from the reader of the affected code and switches the rule
repo-wide for a one-site problem. 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 #### Known issue
- A source-level disable is a _human_ last resort. AI agents must not add one; - Both placements are _human_ last resorts. AI agents must neither add a source
they fix the type at its root (see [AGENTS.md § Never do](../AGENTS.md#never-do)). 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 ### Unwanted stylistic rules are turned off in the config
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "tiny-pattern-ts", "name": "tiny-pattern-ts",
"version": "0.3.0", "version": "0.5.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "tiny-pattern-ts", "name": "tiny-pattern-ts",
"version": "0.3.0", "version": "0.5.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"type-fest": "^5.9.0" "type-fest": "^5.9.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "tiny-pattern-ts", "name": "tiny-pattern-ts",
"version": "0.3.0", "version": "0.5.0",
"description": "Pattern matching for TypeScript/ESM environments (F#-style, not regex)", "description": "Pattern matching for TypeScript/ESM environments (F#-style, not regex)",
"keywords": [ "keywords": [
"adt", "adt",
+276 -11
View File
@@ -1,4 +1,3 @@
/* oxlint-disable typescript/no-floating-promises -- expectTypeOf() is a sync type-assertion library that the type-aware linter misidentifies as a promise */
import { strict as assert } from "node:assert"; import { strict as assert } from "node:assert";
import path from "node:path"; import path from "node:path";
import { test } from "node:test"; import { test } from "node:test";
@@ -24,10 +23,12 @@ test("getMatcher: exhaustive pattern infers one common return type", () => {
const matcher = factory({ const matcher = factory({
a: (s) => { a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">(); expectTypeOf(s).toEqualTypeOf<"a">();
assert.equal(s, "a");
return 1; return 1;
}, },
b: (s) => { b: (s) => {
expectTypeOf(s).toEqualTypeOf<"b">(); expectTypeOf(s).toEqualTypeOf<"b">();
assert.equal(s, "b");
return 2; return 2;
}, },
}); });
@@ -49,10 +50,13 @@ test("getMatcher: dispatches on numeric literal keys", () => {
const matcher = factory({ const matcher = factory({
1: (n) => { 1: (n) => {
expectTypeOf(n).toEqualTypeOf<1>(); expectTypeOf(n).toEqualTypeOf<1>();
// A numeric key must not reach its handler as the string `"1"`.
assert.equal(n, 1);
return n + 1; return n + 1;
}, },
2: (n) => { 2: (n) => {
expectTypeOf(n).toEqualTypeOf<2>(); expectTypeOf(n).toEqualTypeOf<2>();
assert.equal(n, 2);
return n * 10; return n * 10;
}, },
}); });
@@ -71,18 +75,22 @@ test("getMatcher: dispatches on mixed string and numeric keys", () => {
const matcher = factory({ const matcher = factory({
a: (s) => { a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">(); expectTypeOf(s).toEqualTypeOf<"a">();
assert.equal(s, "a");
return 1; return 1;
}, },
b: (s) => { b: (s) => {
expectTypeOf(s).toEqualTypeOf<"b">(); expectTypeOf(s).toEqualTypeOf<"b">();
assert.equal(s, "b");
return 2; return 2;
}, },
1: (n) => { 1: (n) => {
expectTypeOf(n).toEqualTypeOf<1>(); expectTypeOf(n).toEqualTypeOf<1>();
assert.equal(n, 1);
return 10; return 10;
}, },
2: (n) => { 2: (n) => {
expectTypeOf(n).toEqualTypeOf<2>(); expectTypeOf(n).toEqualTypeOf<2>();
assert.equal(n, 2);
return 20; return 20;
}, },
}); });
@@ -95,6 +103,56 @@ test("getMatcher: dispatches on mixed string and numeric keys", () => {
assert.equal(matcher(2), 20); 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 // API: getMatcher — ❌ Exhaustive (fallback) / ✔️ ReturnsStrict
// ============================================================================ // ============================================================================
@@ -108,12 +166,14 @@ test("getMatcher: a `_` fallback receives the unhandled keys", () => {
{ {
a: (s) => { a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">(); expectTypeOf(s).toEqualTypeOf<"a">();
assert.equal(s, "a");
return 1 as const; return 1 as const;
}, },
}, },
(s) => { (s) => {
// The fallback sees only the keys `a` did not handle. // The fallback sees only the keys `a` did not handle.
expectTypeOf(s).toEqualTypeOf<"b" | "c">(); expectTypeOf(s).toEqualTypeOf<"b" | "c">();
assert.ok(s === "b" || s === "c");
return 2 as const; return 2 as const;
}, },
); );
@@ -136,15 +196,20 @@ test("getMatcher: a `_` fallback routes mixed string and numeric gaps", () => {
{ {
a: (s) => { a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">(); expectTypeOf(s).toEqualTypeOf<"a">();
assert.equal(s, "a");
return "A"; return "A";
}, },
1: (n) => { 1: (n) => {
expectTypeOf(n).toEqualTypeOf<1>(); expectTypeOf(n).toEqualTypeOf<1>();
assert.equal(n, 1);
return "one"; return "one";
}, },
}, },
(s) => { (s) => {
expectTypeOf(s).toEqualTypeOf<"b" | 2>(); 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"; return "fallback";
}, },
); );
@@ -157,6 +222,44 @@ test("getMatcher: a `_` fallback routes mixed string and numeric gaps", () => {
assert.equal(matcher(2), "fallback"); 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 // API: getMatcherW — ✔️ Exhaustive / ❌ ReturnsStrict
// ============================================================================ // ============================================================================
@@ -169,10 +272,12 @@ test("getMatcherW: exhaustive pattern widens to the union of handler returns", (
const matcher = factory({ const matcher = factory({
x: (s) => { x: (s) => {
expectTypeOf(s).toEqualTypeOf<"x">(); expectTypeOf(s).toEqualTypeOf<"x">();
assert.equal(s, "x");
return 1 as const; return 1 as const;
}, },
y: (s) => { y: (s) => {
expectTypeOf(s).toEqualTypeOf<"y">(); expectTypeOf(s).toEqualTypeOf<"y">();
assert.equal(s, "y");
return "two" as const; return "two" as const;
}, },
}); });
@@ -194,18 +299,22 @@ test("getMatcherW: dispatches on mixed string and numeric keys", () => {
const matcher = factory({ const matcher = factory({
a: (s) => { a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">(); expectTypeOf(s).toEqualTypeOf<"a">();
assert.equal(s, "a");
return "A" as const; return "A" as const;
}, },
b: (s) => { b: (s) => {
expectTypeOf(s).toEqualTypeOf<"b">(); expectTypeOf(s).toEqualTypeOf<"b">();
assert.equal(s, "b");
return "B" as const; return "B" as const;
}, },
1: (n) => { 1: (n) => {
expectTypeOf(n).toEqualTypeOf<1>(); expectTypeOf(n).toEqualTypeOf<1>();
assert.equal(n, 1);
return 10 as const; return 10 as const;
}, },
2: (n) => { 2: (n) => {
expectTypeOf(n).toEqualTypeOf<2>(); expectTypeOf(n).toEqualTypeOf<2>();
assert.equal(n, 2);
return 20 as const; return 20 as const;
}, },
}); });
@@ -220,6 +329,44 @@ test("getMatcherW: dispatches on mixed string and numeric keys", () => {
assert.equal(matcher(2), 20); assert.equal(matcher(2), 20);
}); });
test("getMatcherW: exhaustive boolean and nullish widen to the union", () => {
// Arrange
const factory = getMatcherW<boolean | null | undefined>();
// Act
const matcher = factory({
true: (s) => {
expectTypeOf(s).toEqualTypeOf<true>();
assert.equal(s, true);
return "yes" as const;
},
false: (s) => {
expectTypeOf(s).toEqualTypeOf<false>();
assert.equal(s, false);
return "no" as const;
},
null: (s) => {
expectTypeOf(s).toEqualTypeOf<null>();
assert.equal(s, null);
return 0 as const;
},
undefined: (s) => {
expectTypeOf(s).toEqualTypeOf<undefined>();
assert.equal(s, undefined);
return 1 as const;
},
});
// Assert
expectTypeOf(matcher).toEqualTypeOf<
(shape: boolean | null | undefined) => "yes" | "no" | 0 | 1
>();
assert.equal(matcher(true), "yes");
assert.equal(matcher(false), "no");
assert.equal(matcher(null), 0);
assert.equal(matcher(undefined), 1);
});
// ============================================================================ // ============================================================================
// API: getMatcherW — ❌ Exhaustive (fallback) / ❌ ReturnsStrict // API: getMatcherW — ❌ Exhaustive (fallback) / ❌ ReturnsStrict
// ============================================================================ // ============================================================================
@@ -233,25 +380,28 @@ test("getMatcherW: a `_` fallback widens gaps into the union", () => {
{ {
x: (s) => { x: (s) => {
expectTypeOf(s).toEqualTypeOf<"x">(); expectTypeOf(s).toEqualTypeOf<"x">();
assert.equal(s, "x");
return 1 as const; return 1 as const;
}, },
}, },
(s) => { (s) => {
// The fallback sees only the keys `x` did not handle. // The fallback sees only the keys `x` did not handle.
expectTypeOf(s).toEqualTypeOf<"y" | "z">(); expectTypeOf(s).toEqualTypeOf<"y" | "z">();
return "fallback" as const; // Returning the shape verbatim lets the `Assert` block check the
// exact value dispatch passed.
return s;
}, },
); );
// Assert // Assert
expectTypeOf(matcher).toEqualTypeOf< expectTypeOf(matcher).toEqualTypeOf<
(shape: "x" | "y" | "z") => 1 | "fallback" (shape: "x" | "y" | "z") => 1 | "y" | "z"
>(); >();
// ❌ a value outside T must not be accepted by the matcher // ❌ a value outside T must not be accepted by the matcher
expectTypeOf<"w">().not.toExtend<Parameters<typeof matcher>[0]>(); expectTypeOf<"w">().not.toExtend<Parameters<typeof matcher>[0]>();
assert.equal(matcher("x"), 1); assert.equal(matcher("x"), 1);
assert.equal(matcher("y"), "fallback"); assert.equal(matcher("y"), "y");
assert.equal(matcher("z"), "fallback"); assert.equal(matcher("z"), "z");
}); });
test("getMatcherW: a `_` fallback widens mixed string and numeric returns", () => { test("getMatcherW: a `_` fallback widens mixed string and numeric returns", () => {
@@ -263,28 +413,34 @@ test("getMatcherW: a `_` fallback widens mixed string and numeric returns", () =
{ {
a: (s) => { a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">(); expectTypeOf(s).toEqualTypeOf<"a">();
assert.equal(s, "a");
return "A" as const; return "A" as const;
}, },
1: (n) => { 1: (n) => {
expectTypeOf(n).toEqualTypeOf<1>(); expectTypeOf(n).toEqualTypeOf<1>();
assert.equal(n, 1);
return 10 as const; return 10 as const;
}, },
}, },
(s) => { (s) => {
expectTypeOf(s).toEqualTypeOf<"b" | 2>(); expectTypeOf(s).toEqualTypeOf<"b" | 2>();
return "fallback" as const; // The number must arrive as `2`, not as its key text `"2"`.
assert.ok(s === "b" || s === 2);
// Returning the shape verbatim lets the `Assert` block check the
// exact value dispatch passed.
return s;
}, },
); );
// Assert // Assert
// ❌ ReturnsStrict: mixed handler returns widen to their union. // ❌ ReturnsStrict: mixed handler returns widen to their union.
expectTypeOf(matcher).toEqualTypeOf< 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("a"), "A");
assert.equal(matcher("b"), "fallback"); assert.equal(matcher("b"), "b");
assert.equal(matcher(1), 10); assert.equal(matcher(1), 10);
assert.equal(matcher(2), "fallback"); assert.equal(matcher(2), 2);
}); });
// ============================================================================ // ============================================================================
@@ -309,6 +465,51 @@ test("getMatcher factory rejects patterns outside its contract", () => {
factory({ a: () => 1 }); factory({ a: () => 1 });
// @ts-expect-error a `string` fallback does not fit the `number` handler // @ts-expect-error a `string` fallback does not fit the `number` handler
factory({ a: () => 1 }, () => "x"); 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({ 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
expectTypeOf(matcher).toEqualTypeOf<(shape: string) => number>();
assert.equal(matcher("a"), 1);
assert.equal(matcher("b"), 2);
}); });
test("getMatcherW factory rejects patterns outside its contract", () => { test("getMatcherW factory rejects patterns outside its contract", () => {
@@ -324,6 +525,8 @@ test("getMatcherW factory rejects patterns outside its contract", () => {
}); });
// @ts-expect-error a gap without a `_` fallback is not exhaustive // @ts-expect-error a gap without a `_` fallback is not exhaustive
factory({ x: () => 1 as const }); 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);
}); });
// ============================================================================ // ============================================================================
@@ -333,7 +536,13 @@ test("getMatcherW factory rejects patterns outside its contract", () => {
test("getMatcher: an unhandled shape throws without a fallback", () => { test("getMatcher: an unhandled shape throws without a fallback", () => {
// Arrange — an open universe types its handler map as an index signature, // Arrange — an open universe types its handler map as an index signature,
// so the type system cannot prove the runtime map is exhaustive. // so the type system cannot prove the runtime map is exhaustive.
const handlers: Record<string, () => number> = { a: () => 1 }; 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); const matcher = getMatcher<string>()(handlers);
// Act / Assert // Act / Assert
@@ -341,6 +550,24 @@ test("getMatcher: an unhandled shape throws without a fallback", () => {
assert.throws(() => matcher("b"), /Unhandled shape: b/); 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 // Autocomplete — the language server is the oracle, not the type system
// ============================================================================ // ============================================================================
@@ -359,6 +586,7 @@ interface LabelsProbe {
readonly name: string; readonly name: string;
readonly factory: "getMatcher" | "getMatcherW"; readonly factory: "getMatcher" | "getMatcherW";
readonly body: string; readonly body: string;
readonly universe?: string;
readonly tail?: string; readonly tail?: string;
} }
@@ -366,6 +594,7 @@ const labelsFor = ({
name, name,
factory, factory,
body, body,
universe = UNIVERSE,
tail = "", tail = "",
}: LabelsProbe): Promise<readonly string[]> => { }: LabelsProbe): Promise<readonly string[]> => {
const session = new LspSession(REPO_ROOT); const session = new LspSession(REPO_ROOT);
@@ -373,7 +602,7 @@ const labelsFor = ({
file: `src/__autocomplete_${name}.ts`, file: `src/__autocomplete_${name}.ts`,
source: [ source: [
`import { ${factory} } from "./index.ts";`, `import { ${factory} } from "./index.ts";`,
`const m = ${factory}<${UNIVERSE}>()({`, `const m = ${factory}<${universe}>()({`,
body, body,
`}${tail});`, `}${tail});`,
"", "",
@@ -489,3 +718,39 @@ test("autocomplete: `getMatcherW` also offers optional keys with a fallback", ()
assert.deepEqual([...result], ["a?", "b?", "c?"]); 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"]);
});
});
+72 -13
View File
@@ -2,13 +2,42 @@ import type { Exact, ValueOf } from "type-fest";
type UnaryFn<T, R> = (shape: T) => R; type UnaryFn<T, R> = (shape: T) => 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;
// `Extract` drops optional handlers (`undefined`) so `PatternReturns` also works // `Extract` drops optional handlers (`undefined`) so `PatternReturns` also works
// when `P`'s constraint has optional keys. // when `P`'s constraint has optional keys.
type PatternReturns<P> = ReturnType< type PatternReturns<P> = ReturnType<
Extract<ValueOf<P>, (...args: never[]) => unknown> Extract<ValueOf<P>, (...args: never[]) => unknown>
>; >;
type Handlers<T extends string | number, R> = { [K in T]: UnaryFn<K, R> }; type Handlers<T extends Matchable, R> = {
[K in PatternKey<T>]: UnaryFn<PatternParam<K>, R>;
};
// The fallback is a *second argument*, not a property of the handler map, // The fallback is a *second argument*, not a property of the handler map,
// because its parameter is the remainder `Exclude<T, keyof Handled>` and TypeScript // because its parameter is the remainder `Exclude<T, keyof Handled>` and TypeScript
@@ -16,11 +45,25 @@ type Handlers<T extends string | number, R> = { [K in T]: UnaryFn<K, R> };
// argument, by contrast, is contextually typed from inference on an earlier // argument, by contrast, is contextually typed from inference on an earlier
// one, so the split is what makes the remainder expressible at all. // one, so the split is what makes the remainder expressible at all.
// See development/library.md. // See development/library.md.
type Fallback<T extends string | number, Handled, R> = UnaryFn< type Fallback<T extends Matchable, Handled, R> = UnaryFn<
Exclude<T, keyof Handled>, Exclude<T, PatternParam<keyof Handled>>,
R R
>; >;
// 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.
interface RedundantFallback {
readonly "every case is already handled, so the fallback is redundant": never;
}
type MustBePartial<T extends Matchable, Handled> =
PatternKey<T> extends keyof Handled ? RedundantFallback : unknown;
// TypeScript does not apply the excess-property check to a generic constraint, // 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 // so `Exact` restores it for the generic forms: a handler map can otherwise
// carry keys outside `T`. // carry keys outside `T`.
@@ -29,9 +72,13 @@ type Fallback<T extends string | number, Handled, R> = UnaryFn<
// Strict returns: one common `R`. Overload order is load-bearing: // Strict returns: one common `R`. Overload order is load-bearing:
// #1 Handlers (first) -> the exhaustive form and the autocomplete popup // #1 Handlers (first) -> the exhaustive form and the autocomplete popup
// #2 Fallback (last) -> accepts a partial handler map plus a fallback // #2 Fallback (last) -> accepts a partial handler map plus a fallback
interface MatcherStrict<T extends string | number> { interface MatcherStrict<T extends Matchable> {
<R>(handlers: Handlers<T, R>): UnaryFn<T, R>; <R>(handlers: Handlers<T, R>): UnaryFn<T, R>;
<R, Handled extends Exact<Partial<Handlers<T, R>>, Handled>>( <
R,
Handled extends Exact<Partial<Handlers<T, R>>, Handled> &
MustBePartial<T, Handled>,
>(
handlers: Handled & Partial<Handlers<T, R>>, handlers: Handled & Partial<Handlers<T, R>>,
fallback: Fallback<T, Handled, R>, fallback: Fallback<T, Handled, R>,
): UnaryFn<T, R>; ): UnaryFn<T, R>;
@@ -42,11 +89,15 @@ interface MatcherStrict<T extends string | number> {
// from the whole handler map, whose closed constraint supplies the // from the whole handler map, whose closed constraint supplies the
// contextual/autocomplete type. // contextual/autocomplete type.
// oxlint-disable typescript/unified-signatures // oxlint-disable typescript/unified-signatures
interface MatcherWidening<T extends string | number> { interface MatcherWidening<T extends Matchable> {
<P extends Exact<Handlers<T, unknown>, P>>( <P extends Exact<Handlers<T, unknown>, P>>(
handlers: P, handlers: P,
): UnaryFn<T, PatternReturns<P>>; ): UnaryFn<T, PatternReturns<P>>;
<R, Handled extends Exact<Partial<Handlers<T, unknown>>, Handled>>( <
R,
Handled extends Exact<Partial<Handlers<T, unknown>>, Handled> &
MustBePartial<T, Handled>,
>(
handlers: Handled, handlers: Handled,
fallback: Fallback<T, Handled, R>, fallback: Fallback<T, Handled, R>,
): UnaryFn<T, PatternReturns<Handled> | R>; ): UnaryFn<T, PatternReturns<Handled> | R>;
@@ -57,19 +108,27 @@ type HandlerMap = Record<string | number, UnaryFn<never, unknown> | undefined>;
const dispatch = const dispatch =
(handlers: HandlerMap, fallback?: UnaryFn<never, unknown>) => (handlers: HandlerMap, fallback?: UnaryFn<never, unknown>) =>
(shape: string | number): unknown => (shape: Matchable): unknown =>
// `handlers[true]` already coerces to the `"true"` property at runtime,
// identical to `handlers[String(shape)]`, so indexing with `shape`
// directly is sound: `shape` is a facade-checked universe member and
// `PatternKey` only ever produces valid property keys. The assertion is
// needed solely because TypeScript forbids indexing with
// `boolean`/`null`/`undefined` (TS2538); it buys the number fast path.
( (
handlers[shape] ?? handlers[
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
shape as string | number
] ??
fallback ?? fallback ??
(() => { (() => {
throw new Error(`Unhandled shape: ${shape}`); throw new Error(`Unhandled shape: ${String(shape)}`);
}) })
)( )(
// oxlint-disable-next-line typescript/no-unsafe-type-assertion // oxlint-disable-next-line typescript/no-unsafe-type-assertion
shape as never, shape as never,
); );
export const getMatcher = <T extends string | number>(): MatcherStrict<T> => export const getMatcher = <T extends Matchable>(): MatcherStrict<T> => dispatch;
dispatch; export const getMatcherW = <T extends Matchable>(): MatcherWidening<T> =>
export const getMatcherW = <T extends string | number>(): MatcherWidening<T> =>
dispatch; dispatch;
@@ -1,4 +1,3 @@
/* oxlint-disable typescript/no-floating-promises -- expectTypeOf() is a sync type-assertion library that the type-aware linter misidentifies as a promise */
import { strict as assert } from "node:assert"; import { strict as assert } from "node:assert";
import path from "node:path"; import path from "node:path";
import { test } from "node:test"; import { test } from "node:test";