🔀 Merge chore/adopt-three-overload-matcher into main

This commit is contained in:
tmu committed 2026-09-17 21:56:41 +00:00
commit 6f87c2f3b5
8 files changed
+643 -279

No files matched your search

+5
View File
@@ -67,6 +67,11 @@ before the implementation. The loop is **type → red → green → refactor**:
`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.
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 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 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 factory), `// Act` exercises the subject once from them (not a second
+16
View File
@@ -27,6 +27,21 @@ v1.0:
☐ Achieve 100% branch coverage on `src/primitive.ts` ☐ Achieve 100% branch coverage on `src/primitive.ts`
☐ Achieve 100% branch coverage on `src/index.ts` ☐ Achieve 100% branch coverage on `src/index.ts`
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<…>>`
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
→ today `_: (shape: T) => R`; desired `_: (shape: Exclude<T, handledKeys>) => R`
☐ An exhaustive pattern that also carries `_` must be a compile error @medium
→ `{ a, b, _ }` for `T = "a" | "b"` is accepted today; the redundant `_` should be rejected
Bugs: Bugs:
Enhancements: Enhancements:
@@ -41,6 +56,7 @@ Documentation:
☐ Add comparison section vs. other TS pattern-matching libs in Readme.md ☐ Add comparison section vs. other TS pattern-matching libs in Readme.md
☐ Write migration guide for users coming from discriminated unions ☐ Write migration guide for users coming from discriminated unions
☐ Create backlog tasks for implementation ☐ Create backlog tasks for implementation
✔ Document the matcher design paths in `development/` — union vs overload merge, inferred universe (`NoInfer`), conditional `RequireKeys`, cases-first — and why each was abandoned @done
☐ Validate code fences in Markdown (start with README.md) — compile the TypeScript examples against `src/` so the docs cannot drift from the API ☐ Validate code fences in Markdown (start with README.md) — compile the TypeScript examples against `src/` so the docs cannot drift from the API
Workflow: Workflow:
+88 -1
View File
@@ -3,4 +3,91 @@
The type-level design of the public API and the limitations it carries. The The type-level design of the public API and the limitations it carries. The
user-facing reference is [README § API](../README.md#api). user-facing reference is [README § API](../README.md#api).
Currently the library is placeholder code. The matcher below is implemented in `src/primitive.ts` and re-exported from
`src/index.ts` as `getMatcher` / `getMatcherW`; the rest of the library is
placeholder code.
## Matcher shape
#### Decision (2026-09)
A matcher is built by a factory and applied to a pattern:
```ts
const matcher = getMatcher<"a" | "b">()({ a: (s) => …, b: (s) => … });
```
Whether the pattern is exhaustive or has a fallback is decided **at the call
site**, by whether it carries `_` — F#'s `| _ ->`. Only the return-strictness
axis remains, so there are two factories:
- `getMatcher` — one common `R`, the best common return type of every handler;
- `getMatcherW` — the union of every handler's return type.
Both are three overloads whose order is load-bearing:
1. `ExhaustiveLoose<R, T>` = `{ [K in T]: UnaryFn<K, R> } & { _?: UnaryFn<T, R> }`
2. `Fallback<R, T>` = `Partial<…> & { _: UnaryFn<T, R> }`
3. `Handlers<R, T>` — the pure exhaustive shape
#### Why
- **Autocomplete reads the first overload, the error reads the last.**
TypeScript takes the first overload signature as the contextual type for the
object-literal popup, and the last for `No overload matches this call`. So
`ExhaustiveLoose` first yields the popup `_?, a, b` (T-keys required, `_`
optional) while `Handlers` last yields `Property 'b' is missing`. The two can be
tuned independently.
- Two factories, not four: the fallback is a pattern _shape_, not a separate API.
- The widened return union is derived from the pattern's handler types, so it
needs no fourth signature.
#### Rejected
- **Four factories** (exhaustive and fallback each split by return handling).
The exhaustive/fallback axis is expressible as one pattern type; four
signatures duplicate it.
- **Union merge** — one type `Exhaustive<R,T> | (Partial<…> & { _: … })`,
explicit `<T>()`. Type-safe and completable, but TypeScript reports the
near-miss union member, so a missing key reads `Property '_' is missing`
instead of naming the key. Arm order does not change the report; the overload
split does.
- **Overload merge with only the exhaustive arm last.** Fixes the missing-key
message, but a wrong `_` parameter is then reported against the exhaustive
arm, and `Parameters<typeof factory>` sees only one arm.
- **Inferred universe** — `match(pattern)` with `T` taken from the keys
(exhaustive) or from `_`'s annotated parameter (fallback), via `NoInfer<T>` and
`_?: never`, split by overloads (a plain union merges inference; measured
`T = "_" | "a"`). No explicit `<T>`, and pipe-friendly. Rejected because: with
no declared universe the exhaustive popup offers only `_`; an unannotated `_`
widens `T` to `string | number`; and `NoInfer` leaks into the emitted `.d.ts`,
raising the consumer floor to TypeScript 5.4 (README promises `>= 5.0`).
- **Conditional `RequireKeys`** — parameter
`P & ("_" extends keyof P ? unknown : Handlers<R, T>)`. Gives the good
missing-key message, but `keyof P` counts _optional_ keys: a widened value
whose declared type has `_?:` bypasses the completeness check. Demanding a
required `_` instead rejects that case but breaks `P` inference — `P` falls back
to its constraint and partial literals then demand every key. Typos also need a
`NoExtra` guard, whose message degrades to `not assignable to never`.
- **Cases-first curried** — `match(["a", "b"])({ a: …, b: … })`. Completion works
for exhaustive patterns, and the array is a single source of truth for the
runtime list and the union. Rejected as not pipe-friendly; it needs a runtime
array; and the single-call form `match(cases, pattern)` cannot infer `R` (the
mapped key type `K[number]` stays deferred, so `R` widens to `unknown`).
#### Known issue
- The `_` handler receives **all** of `T`, not the unhandled subset
(`Exclude<T, handledKeys>`).
- An exhaustive pattern that also carries `_` is accepted; the redundant `_`
should be a compile error.
- The widened overloads carry a completeness guard
`keyof P extends T | "_" ? unknown : never`, because TypeScript does not apply
the excess-property check to a generic constraint: a generic parameter accepts
extra keys, a parameter typed as a concrete object type does not.
`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).
+59 -6
View File
@@ -30,7 +30,8 @@ before the implementation.
- Testing the type only: it would not catch handler dispatch or the `_` - Testing the type only: it would not catch handler dispatch or the `_`
fallback (see `src/primitive.test.ts`). 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). [CONTRIBUTING.md § Development commands](../CONTRIBUTING.md#development-commands).
`c8` uses V8 coverage, so the `--strip-types` source is instrumented without a `c8` uses V8 coverage, so the `--strip-types` source is instrumented without a
build step, and the runner relies on the `.ts` import-extension convention (see build step, and the runner relies on the `.ts` import-extension convention (see
@@ -95,7 +96,11 @@ import { LspSession } from "#test-utils/lsp-completion.ts";
loader needed. loader needed.
- The helper uses `node:` builtins (it drives a language server), so - The helper uses `node:` builtins (it drives a language server), so
`import/no-nodejs-modules` is off for `src/util/__tests__/**` in `import/no-nodejs-modules` is off for `src/util/__tests__/**` in
`.oxlintrc.json` — the same exception the old `scripts/**` scope carried. `.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 #### Rejected
@@ -111,11 +116,59 @@ import { LspSession } from "#test-utils/lsp-completion.ts";
still guards non-test helpers importing each other, and the exemption is still guards non-test helpers importing each other, and the exemption is
only needed for the one specifier the mapping already solves cleanly. 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 ## Known issues
- The type-aware linter misidentifies `expectTypeOf()` as a floating promise, so - The type-aware linter misidentifies `expectTypeOf()` as a floating promise, so
test files that use it (`src/primitive.test.ts`) carry a test files that use it (`src/primitive.test.ts`) carry a file-level
file-level `oxlint-disable `oxlint-disable typescript/no-floating-promises` with an explanatory comment.
typescript/no-floating-promises` with an explanatory comment. It is a known It is a known false positive, not a rule worth disabling project-wide (see
false positive, not a rule worth disabling project-wide (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)).
+1 -6
View File
@@ -1,6 +1 @@
export { export { getMatcher, getMatcherW } from "./primitive.ts";
getPrimitiveUnionMatcher,
getPrimitiveUnionMatcherPartial,
getPrimitiveUnionMatcherPartialW,
getPrimitiveUnionMatcherW,
} from "./primitive.ts";
+305 -205
View File
@@ -1,50 +1,49 @@
/* oxlint-disable typescript/no-floating-promises -- expectTypeOf() is a sync type-assertion library that the type-aware linter misidentifies as a promise */ /* 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 { test } from "node:test"; import { test } from "node:test";
import { expectTypeOf } from "expect-type"; import { expectTypeOf } from "expect-type";
import { import {
getPrimitiveUnionMatcher, type CompletionTarget,
getPrimitiveUnionMatcherPartial, LspSession,
getPrimitiveUnionMatcherPartialW, } from "#test-utils/lsp-completion.ts";
getPrimitiveUnionMatcherW,
} from "./primitive.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 // Arrange
const factory = getPrimitiveUnionMatcherW<"a" | "b">(); const factory = getMatcher<"a" | "b">();
// Act // Act
const matcher = factory({ const matcher = factory({
a: (s) => { a: (s): number => {
expectTypeOf(s).toEqualTypeOf<"a">(); expectTypeOf(s).toEqualTypeOf<"a">();
return 1 as const; return 1;
}, },
b: (s) => { b: (s): 1 | 2 => {
expectTypeOf(s).toEqualTypeOf<"b">(); expectTypeOf(s).toEqualTypeOf<"b">();
return "two" as const; return 2;
}, },
}); });
// Assert // Assert
// ❌ ReturnsStrict: mixed handler returns widen to their union. // ✔️ ReturnsStrict: R is the best common return type, not a widening union.
expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b") => 1 | "two">(); expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b") => number>();
// A pattern missing a key must not satisfy the parameter type. // ❌ a value outside T must not be accepted by the matcher
expectTypeOf<{ expectTypeOf<"c">().not.toExtend<Parameters<typeof matcher>[0]>();
a: () => number;
}>().not.toExtend<Parameters<typeof factory>[0]>();
assert.equal(matcher("a"), 1); 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 // Arrange
const factory = getPrimitiveUnionMatcherW<1 | 2>(); const factory = getMatcher<1 | 2>();
// Act // Act
const matcher = factory({ const matcher = factory({
@@ -64,9 +63,150 @@ test("getPrimitiveUnionMatcherW dispatches on numeric literal keys", () => {
assert.equal(matcher(2), 20); assert.equal(matcher(2), 20);
}); });
test("getPrimitiveUnionMatcherW dispatches on mixed string and numeric keys", () => { test("getMatcher: dispatches on mixed string and numeric keys", () => {
// Arrange // Arrange
const factory = getPrimitiveUnionMatcherW<"a" | "b" | 1 | 2>(); const factory = getMatcher<"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
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: getMatcher — ❌ Exhaustive (fallback) / ✔️ ReturnsStrict
// ============================================================================
test("getMatcher: a `_` fallback makes the universe keys optional", () => {
// Arrange
const factory = getMatcher<"a" | "b" | "c">();
// 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>();
// ❌ 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 also accepts an exhaustive pattern", () => {
// Arrange
const factory = getMatcher<"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("getMatcher: a `_` fallback routes mixed string and numeric gaps", () => {
// Arrange
const factory = getMatcher<"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 => {
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: 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">();
return 1 as const;
},
y: (s) => {
expectTypeOf(s).toEqualTypeOf<"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 // Act
const matcher = factory({ const matcher = factory({
@@ -99,181 +239,12 @@ test("getPrimitiveUnionMatcherW dispatches on mixed string and numeric keys", ()
}); });
// ============================================================================ // ============================================================================
// API: getPrimitiveUnionMatcher — ✔️ Exhaustive / ✔️ ReturnsStrict // API: getMatcherW — ❌ Exhaustive (fallback) / ❌ ReturnsStrict
// ============================================================================ // ============================================================================
test("getPrimitiveUnionMatcher infers a single return type shared by all handlers", () => { test("getMatcherW: a `_` fallback widens gaps into the union", () => {
// Arrange // Arrange
const factory = getPrimitiveUnionMatcher<"a" | "b">(); const factory = getMatcherW<"x" | "y" | "z">();
// Act
const matcher = factory({
a: (s): number => {
expectTypeOf(s).toEqualTypeOf<"a">();
return 1;
},
b: (s): 1 | 2 => {
expectTypeOf(s).toEqualTypeOf<"b">();
return 2;
},
});
// 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}`;
},
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 // Act
const matcher = factory({ const matcher = factory({
@@ -292,18 +263,16 @@ test("getPrimitiveUnionMatcherPartialW allows gaps and widens to the union of ha
expectTypeOf(matcher).toEqualTypeOf< expectTypeOf(matcher).toEqualTypeOf<
(shape: "x" | "y" | "z") => 1 | "fallback" (shape: "x" | "y" | "z") => 1 | "fallback"
>(); >();
// ❌ Exhaustive: gaps are allowed, but only with a `_` fallback. // ❌ a value outside T must not be accepted by the matcher
expectTypeOf<{ expectTypeOf<"w">().not.toExtend<Parameters<typeof matcher>[0]>();
x: () => number;
}>().not.toExtend<Parameters<typeof sparseFactory>[0]>();
assert.equal(matcher("x"), 1); assert.equal(matcher("x"), 1);
assert.equal(matcher("y"), "fallback"); assert.equal(matcher("y"), "fallback");
assert.equal(matcher("z"), "fallback"); assert.equal(matcher("z"), "fallback");
}); });
test("getPrimitiveUnionMatcherPartialW widens mixed string and numeric key returns to their union", () => { test("getMatcherW: a `_` fallback widens mixed string and numeric returns", () => {
// Arrange // Arrange
const factory = getPrimitiveUnionMatcherPartialW<"a" | "b" | 1 | 2>(); const factory = getMatcherW<"a" | "b" | 1 | 2>();
// Act // Act
const matcher = factory({ const matcher = factory({
@@ -316,7 +285,6 @@ test("getPrimitiveUnionMatcherPartialW widens mixed string and numeric key retur
return 10 as const; return 10 as const;
}, },
_: (s) => { _: (s) => {
// The fallback sees the whole union, not a single literal.
expectTypeOf(s).toEqualTypeOf<"a" | "b" | 1 | 2>(); expectTypeOf(s).toEqualTypeOf<"a" | "b" | 1 | 2>();
return "fallback" as const; return "fallback" as const;
}, },
@@ -333,9 +301,9 @@ test("getPrimitiveUnionMatcherPartialW widens mixed string and numeric key retur
assert.equal(matcher(2), "fallback"); assert.equal(matcher(2), "fallback");
}); });
test("getPrimitiveUnionMatcherPartialW also accepts an exhaustive pattern", () => { test("getMatcherW: a `_` fallback also accepts an exhaustive pattern", () => {
// Arrange // Arrange
const factory = getPrimitiveUnionMatcherPartialW<"x" | "y">(); const factory = getMatcherW<"x" | "y">();
// Act // Act
const matcher = factory({ const matcher = factory({
@@ -350,8 +318,140 @@ test("getPrimitiveUnionMatcherPartialW also accepts an exhaustive pattern", () =
}); });
// Assert // Assert
// ❌ ReturnsStrict: mixed handler returns widen to their union.
expectTypeOf(matcher).toEqualTypeOf<(shape: "x" | "y") => 1 | "two">(); expectTypeOf(matcher).toEqualTypeOf<(shape: "x" | "y") => 1 | "two">();
assert.equal(matcher("x"), 1); assert.equal(matcher("x"), 1);
assert.equal(matcher("y"), "two"); assert.equal(matcher("y"), "two");
}); });
// ============================================================================
// Factory contracts — calls that must not compile
// ============================================================================
test("getMatcher factory rejects patterns outside its contract", () => {
// Arrange
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 `_` is not exhaustive
factory({ a: () => 1 });
});
test("getMatcherW factory rejects patterns outside its contract", () => {
// Arrange
const factory = getMatcherW<"x" | "y">();
// Act / Assert — the calls below must not compile
// @ts-expect-error `z` is not part of the universe `"x" | "y"`
factory({
x: () => 1 as const,
y: () => 2 as const,
z: () => 3 as const,
});
// @ts-expect-error a gap without `_` is not exhaustive
factory({ x: () => 1 as const });
});
// ============================================================================
// 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.
const labelsFor = (
name: string,
factory: "getMatcher" | "getMatcherW",
body: string,
): 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,
"});",
"",
].join("\n"),
};
return session
.completionLabelsAt(target)
.then((result) => result.labels)
.finally(() => session.close());
};
test("autocomplete: an exhaustive pattern requires the universe, `_` optional", () => {
// Arrange
const name = "getMatcher_fresh";
// Act
const labels = labelsFor(name, "getMatcher", " /*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,
"getMatcher",
" a: () => 1,\n /*COMPLETE*/",
);
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["_?", "b", "c"]);
});
});
test("autocomplete: `_` makes the remaining keys optional", () => {
// Arrange
const name = "getMatcher_after_fallback";
// Act
const labels = labelsFor(
name,
"getMatcher",
" _: () => 0,\n /*COMPLETE*/",
);
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["a?", "b?", "c?"]);
});
});
test("autocomplete: `getMatcherW` offers the same popup as `getMatcher`", () => {
// Arrange
const name = "getMatcherW_fresh";
// Act
const labels = labelsFor(name, "getMatcherW", " /*COMPLETE*/");
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["_?", "a", "b", "c"]);
});
});
+53 -61
View File
@@ -1,71 +1,63 @@
import type { Simplify, ValueOf } from "type-fest"; import type { ValueOf } from "type-fest";
type UnaryFn<T, R> = (shape: T) => R; type UnaryFn<T, R> = (shape: T) => R;
// ============================================================================ // `Extract` drops optional handlers (`undefined`) so `PatternReturns` also works
// ✔️ Exhaustive // when `P`'s constraint has optional keys.
// ❌ ReturnsStrict type PatternReturns<P> = ReturnType<
// ============================================================================ Extract<ValueOf<P>, (...args: never[]) => unknown>
type PatternPrimitiveUnion<R, T extends string | number> = { >;
[K in T]: UnaryFn<K, R>;
type Handlers<R, T extends string | number> = { [K in T]: UnaryFn<K, R> };
// Fallback form: `_` is a required key, the T-keys are optional.
type Fallback<R, T extends string | number> = Partial<Handlers<R, T>> & {
_: UnaryFn<T, R>;
}; };
type PatternReturns< // Completion form: T-keys required, `_` optional. Overload #1, because
P extends Record<string | number, UnaryFn<never, unknown>>, // TypeScript takes the *first* overload as the contextual type for the popup.
> = ReturnType<ValueOf<P>>; type ExhaustiveLoose<R, T extends string | number> = Handlers<R, T> & {
_?: UnaryFn<T, R>;
};
export const getPrimitiveUnionMatcherW: <T extends string | number>() => < // oxlint-disable typescript/unified-signatures
P extends PatternPrimitiveUnion<unknown, T>, // Strict returns: one common `R`. Overload order is load-bearing:
>( // #1 ExhaustiveLoose -> autocomplete `_?, a, b`
pattern: Simplify<P>, // #2 Fallback -> accepts a partial pattern
) => UnaryFn<T, PatternReturns<P>> = () => (pattern) => (shape) => // #3 Handlers (last) -> "Property 'b' is missing" is the reported error
// Rewrite not to use any is possible, was evaluated and solutions were interface MatcherStrict<T extends string | number> {
// more complex than the current solution. <R>(pattern: ExhaustiveLoose<R, T>): UnaryFn<T, R>;
<R>(pattern: Fallback<R, T>): UnaryFn<T, R>;
<R>(pattern: Handlers<R, T>): UnaryFn<T, R>;
}
// oxlint-enable typescript/unified-signatures
// oxlint-disable-next-line typescript/no-explicit-any typescript/no-unsafe-type-assertion // Widened returns: the union of every handler's return type. `P` is inferred
(pattern[shape] as any)(shape); // from the whole parameter, whose closed constraint supplies the
// contextual/autocomplete type; the `keyof P` guard appended to each overload
// rejects keys outside `T`/`_`.
interface MatcherWidening<T extends string | number> {
<P extends ExhaustiveLoose<unknown, T>>(
pattern: P & (keyof P extends T | "_" ? unknown : never),
): UnaryFn<T, PatternReturns<P>>;
<P extends Fallback<unknown, T>>(
pattern: P & (keyof P extends T | "_" ? unknown : never),
): UnaryFn<T, PatternReturns<P>>;
<P extends Handlers<unknown, T>>(
pattern: P & (keyof P extends T | "_" ? unknown : never),
): UnaryFn<T, PatternReturns<P>>;
}
// ============================================================================ type HandlerMap = Record<string | number, UnaryFn<never, unknown> | undefined>;
// ✔️ Exhaustive
// ✔️ ReturnsStrict
// ============================================================================
export const getPrimitiveUnionMatcher: <T extends string | number>() => <R>(
pattern: Simplify<PatternPrimitiveUnion<R, T>>,
) => UnaryFn<T, R> = getPrimitiveUnionMatcherW;
// ============================================================================ const dispatch =
// ❌ Exhaustive (pattern: HandlerMap) =>
// ✔️ ReturnsStrict (shape: string | number): unknown =>
// ============================================================================ // oxlint-disable-next-line typescript/no-non-null-assertion typescript/no-unsafe-type-assertion
type PatternPrimitiveUnionPartial<R, T extends string | number> = (pattern[shape] ?? pattern["_"]!)(shape as never);
| PatternPrimitiveUnion<R, T>
| (Partial<PatternPrimitiveUnion<R, T>> & {
_: UnaryFn<T, R>;
});
export const getPrimitiveUnionMatcherPartial: <T extends string | number>() => < export const getMatcher = <T extends string | number>(): MatcherStrict<T> =>
R, dispatch;
>( export const getMatcherW = <T extends string | number>(): MatcherWidening<T> =>
pattern: Simplify<PatternPrimitiveUnionPartial<R, T>>, dispatch;
) => UnaryFn<T, R> = () => (pattern) => (shape) =>
// Rewrite not to use any is possible, was evaluated and solutions were
// more complex than the current solution.
// oxlint-disable-next-line typescript/no-explicit-any typescript/no-unsafe-type-assertion
(pattern[shape] ?? (pattern as any)["_"])(shape);
// ============================================================================
// ❌ 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;
+116
View File
@@ -1,5 +1,6 @@
/* oxlint-disable typescript/no-floating-promises -- expectTypeOf() is a sync type-assertion library that the type-aware linter misidentifies as a promise */ /* 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 { test } from "node:test"; import { test } from "node:test";
import { expectTypeOf } from "expect-type"; import { expectTypeOf } from "expect-type";
@@ -10,6 +11,8 @@ import {
LspSession, LspSession,
} from "#test-utils/lsp-completion.ts"; } 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 // 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 // scattered test files take to reach test helpers; this pins that it resolves
// and types without starting a language server // and types without starting a language server
@@ -30,3 +33,116 @@ test("test-helper home: `#test-utils/…` resolves to the helper and types it",
assert.equal(typeof LspSession, "function"); assert.equal(typeof LspSession, "function");
assert.equal(target.file, "src/util/__tests__/lsp-completion.ts"); 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/);
});