Compare commits
7
Commits
0.3.0
..
eeb831b717
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eeb831b717 | ||
|
|
ae7ffb6fa2 | ||
|
|
0d6a3f1b9b | ||
|
|
8615723c64 | ||
|
|
935e82d205 | ||
|
|
c7223dbe69 | ||
|
|
2d3577716d |
No files matched your search
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [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.3.0] - 2026-09-18
|
||||
|
||||
- condensed primitive matcher factories down to 2 from formerly 4
|
||||
|
||||
+4
-4
@@ -42,10 +42,10 @@ 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
|
||||
✔ `_` should receive only the unhandled `T` keys, not all of `T` @medium @done
|
||||
→ the fallback is now a second argument: `(handlers, (s) => …)`, `s: Exclude<T, keyof handlers>`
|
||||
☐ A fallback for an already-exhaustive handler map must be a compile error @medium
|
||||
→ `(handlers, fallback)` with `handlers` covering all of `T` is accepted; the redundant fallback should be rejected (currying would allow the guard)
|
||||
|
||||
Bugs:
|
||||
✔ TS 7 LSP server logs `context canceled` on stderr at shutdown @done
|
||||
|
||||
+55
-59
@@ -3,7 +3,7 @@
|
||||
The type-level design of the public API and the limitations it carries. The
|
||||
user-facing reference is [README § API](../README.md#api).
|
||||
|
||||
The matcher below is implemented in `src/primitive.ts` and re-exported from
|
||||
The matcher is implemented in `src/primitive.ts` and re-exported from
|
||||
`src/index.ts` as `getMatcher` / `getMatcherW`; the rest of the library is
|
||||
placeholder code.
|
||||
|
||||
@@ -11,81 +11,77 @@ placeholder code.
|
||||
|
||||
#### Decision (2026-09)
|
||||
|
||||
A matcher is built by a factory and applied to a pattern:
|
||||
A factory takes the universe and returns a builder; the builder takes a handler
|
||||
map and an optional fallback:
|
||||
|
||||
```ts
|
||||
const matcher = getMatcher<"a" | "b">()({ a: (s) => …, b: (s) => … });
|
||||
const fallback = getMatcher<"a" | "b" | "c">()({ a: (s) => … }, (s) => …);
|
||||
```
|
||||
|
||||
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:
|
||||
Exhaustive or fallback is decided **at the call site**, by whether the second
|
||||
argument is present. The fallback's parameter is the remainder
|
||||
`Exclude<T, keyof Handled>`. Only the return-strictness axis remains, so there
|
||||
are two factories:
|
||||
|
||||
- `getMatcher` — one common `R`, the best common return type of every handler;
|
||||
- `getMatcherW` — the union of every handler's return type.
|
||||
- `getMatcher` — one common `R`; the fallback must fit it;
|
||||
- `getMatcherW` — the union `PatternReturns<Handled> | R`.
|
||||
|
||||
Both are three overloads whose order is load-bearing:
|
||||
Each factory is two 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
|
||||
1. `Handlers<T, R>` — the exhaustive form, and the contextual type of the
|
||||
handler-map popup;
|
||||
2. `Handled extends Exact<Partial<Handlers<T, R>>, Handled>` plus
|
||||
`Fallback<T, Handled, R>` — a partial handler map plus the fallback.
|
||||
|
||||
#### 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.
|
||||
- **The fallback is an argument, not a property.** TypeScript fixes a property's
|
||||
contextual type before it infers its sibling keys, so `_: (s) => …` in the
|
||||
handler map can only see all of `T`, never `Exclude<T, keyof Handled>`. A later
|
||||
argument is contextually typed from inference on an earlier one, so the split
|
||||
is what makes the remainder expressible.
|
||||
- **Overload order keeps both messages.** #1 supplies the contextual type
|
||||
(`a, b, c`); #2 accepts a partial map once a fallback is present, so its popup
|
||||
is optional (`a?, b?, c?`). A gap without a fallback is reported against #1.
|
||||
- **`R` needs an inference site.** `R` inside the `Exact<…>` constraint is not
|
||||
one, so `handlers: Handled & Partial<Handlers<T, R>>` re-adds it; without that
|
||||
`R` collapses to `unknown` when the handler params are inferred.
|
||||
- **`Exact` restores the excess-property check.** TypeScript skips it for a
|
||||
generic constraint, so without `Exact` the handler map accepts keys outside
|
||||
`T`.
|
||||
- Two factories, not four: the fallback is an argument, not a separate API.
|
||||
|
||||
#### Rejected
|
||||
|
||||
- **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`).
|
||||
- **Single-object `_`** (the former shape). `_` sees only all of `T`; the
|
||||
remainder is not expressible there, and an exhaustive map plus `_` was
|
||||
accepted.
|
||||
- **Curried handlers-first** — `(handlers)(fallback)`. `Handled` is fixed before
|
||||
the second call, so a redundant-fallback guard would work. Rejected: two calls
|
||||
for the common case.
|
||||
- **`this` / HKT self-reference.** `this` is post-construction (method bodies,
|
||||
return positions); a parameter's contextual type is pre-construction.
|
||||
`keyof this` in an interface method is the interface, not the literal.
|
||||
- **Variance / `const` type parameters / `NoInfer` / `unique symbol` brands /
|
||||
defaulted type-param guards.** None change inference or evaluation order;
|
||||
`in`/`out` on the handler map broke contextual typing outright. `NoInfer`
|
||||
specifically leaks into the emitted `.d.ts`, raising the consumer floor to
|
||||
TypeScript 5.4 (README promises `>= 5.0`).
|
||||
- **Union merge**, **overload merge with only the exhaustive arm last**,
|
||||
**inferred universe**, **conditional `RequireKeys`**, **cases-first curried** —
|
||||
decided against while the API was single-object; their reasons (reported
|
||||
near-miss member, no `_` in the exhaustive popup, `NoInfer`/floor, `keyof P`
|
||||
counts optional keys, not pipe-friendly) hold where they still apply.
|
||||
|
||||
#### Known issue
|
||||
|
||||
- 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
|
||||
- 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
|
||||
`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
|
||||
|
||||
+133
-99
@@ -22,11 +22,11 @@ test("getMatcher: exhaustive pattern infers one common return type", () => {
|
||||
|
||||
// Act
|
||||
const matcher = factory({
|
||||
a: (s): number => {
|
||||
a: (s) => {
|
||||
expectTypeOf(s).toEqualTypeOf<"a">();
|
||||
return 1;
|
||||
},
|
||||
b: (s): 1 | 2 => {
|
||||
b: (s) => {
|
||||
expectTypeOf(s).toEqualTypeOf<"b">();
|
||||
return 2;
|
||||
},
|
||||
@@ -69,19 +69,19 @@ test("getMatcher: dispatches on mixed string and numeric keys", () => {
|
||||
|
||||
// Act
|
||||
const matcher = factory({
|
||||
a: (s): number => {
|
||||
a: (s) => {
|
||||
expectTypeOf(s).toEqualTypeOf<"a">();
|
||||
return 1;
|
||||
},
|
||||
b: (s): 1 | 2 => {
|
||||
b: (s) => {
|
||||
expectTypeOf(s).toEqualTypeOf<"b">();
|
||||
return 2;
|
||||
},
|
||||
1: (n): number => {
|
||||
1: (n) => {
|
||||
expectTypeOf(n).toEqualTypeOf<1>();
|
||||
return 10;
|
||||
},
|
||||
2: (n): number => {
|
||||
2: (n) => {
|
||||
expectTypeOf(n).toEqualTypeOf<2>();
|
||||
return 20;
|
||||
},
|
||||
@@ -99,22 +99,24 @@ test("getMatcher: dispatches on mixed string and numeric keys", () => {
|
||||
// API: getMatcher — ❌ Exhaustive (fallback) / ✔️ ReturnsStrict
|
||||
// ============================================================================
|
||||
|
||||
test("getMatcher: a `_` fallback makes the universe keys optional", () => {
|
||||
test("getMatcher: a `_` fallback receives the unhandled keys", () => {
|
||||
// Arrange
|
||||
const factory = getMatcher<"a" | "b" | "c">();
|
||||
|
||||
// Act
|
||||
const matcher = factory({
|
||||
a: (s): 1 | 2 => {
|
||||
const matcher = factory(
|
||||
{
|
||||
a: (s) => {
|
||||
expectTypeOf(s).toEqualTypeOf<"a">();
|
||||
return 1;
|
||||
return 1 as const;
|
||||
},
|
||||
_: (s): 1 | 2 => {
|
||||
// The fallback sees the whole union, not a single literal.
|
||||
expectTypeOf(s).toEqualTypeOf<"a" | "b" | "c">();
|
||||
return 2;
|
||||
},
|
||||
});
|
||||
(s) => {
|
||||
// The fallback sees only the keys `a` did not handle.
|
||||
expectTypeOf(s).toEqualTypeOf<"b" | "c">();
|
||||
return 2 as const;
|
||||
},
|
||||
);
|
||||
|
||||
// Assert
|
||||
expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b" | "c") => 1 | 2>();
|
||||
@@ -125,47 +127,27 @@ test("getMatcher: a `_` fallback makes the universe keys optional", () => {
|
||||
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 => {
|
||||
const matcher = factory(
|
||||
{
|
||||
a: (s) => {
|
||||
expectTypeOf(s).toEqualTypeOf<"a">();
|
||||
return "A";
|
||||
},
|
||||
1: (n): string => {
|
||||
1: (n) => {
|
||||
expectTypeOf(n).toEqualTypeOf<1>();
|
||||
return "one";
|
||||
},
|
||||
_: (s): string => {
|
||||
expectTypeOf(s).toEqualTypeOf<"a" | "b" | 1 | 2>();
|
||||
},
|
||||
(s) => {
|
||||
expectTypeOf(s).toEqualTypeOf<"b" | 2>();
|
||||
return "fallback";
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
// Assert
|
||||
expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b" | 1 | 2) => string>();
|
||||
@@ -247,17 +229,19 @@ test("getMatcherW: a `_` fallback widens gaps into the union", () => {
|
||||
const factory = getMatcherW<"x" | "y" | "z">();
|
||||
|
||||
// Act
|
||||
const matcher = factory({
|
||||
const matcher = factory(
|
||||
{
|
||||
x: (s) => {
|
||||
expectTypeOf(s).toEqualTypeOf<"x">();
|
||||
return 1 as const;
|
||||
},
|
||||
_: (s) => {
|
||||
// The fallback sees the whole union, not a single literal.
|
||||
expectTypeOf(s).toEqualTypeOf<"x" | "y" | "z">();
|
||||
},
|
||||
(s) => {
|
||||
// The fallback sees only the keys `x` did not handle.
|
||||
expectTypeOf(s).toEqualTypeOf<"y" | "z">();
|
||||
return "fallback" as const;
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
// Assert
|
||||
expectTypeOf(matcher).toEqualTypeOf<
|
||||
@@ -275,7 +259,8 @@ test("getMatcherW: a `_` fallback widens mixed string and numeric returns", () =
|
||||
const factory = getMatcherW<"a" | "b" | 1 | 2>();
|
||||
|
||||
// Act
|
||||
const matcher = factory({
|
||||
const matcher = factory(
|
||||
{
|
||||
a: (s) => {
|
||||
expectTypeOf(s).toEqualTypeOf<"a">();
|
||||
return "A" as const;
|
||||
@@ -284,11 +269,12 @@ test("getMatcherW: a `_` fallback widens mixed string and numeric returns", () =
|
||||
expectTypeOf(n).toEqualTypeOf<1>();
|
||||
return 10 as const;
|
||||
},
|
||||
_: (s) => {
|
||||
expectTypeOf(s).toEqualTypeOf<"a" | "b" | 1 | 2>();
|
||||
},
|
||||
(s) => {
|
||||
expectTypeOf(s).toEqualTypeOf<"b" | 2>();
|
||||
return "fallback" as const;
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
// Assert
|
||||
// ❌ ReturnsStrict: mixed handler returns widen to their union.
|
||||
@@ -301,28 +287,6 @@ test("getMatcherW: a `_` fallback widens mixed string and numeric returns", () =
|
||||
assert.equal(matcher(2), "fallback");
|
||||
});
|
||||
|
||||
test("getMatcherW: a `_` fallback also accepts an exhaustive pattern", () => {
|
||||
// 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
|
||||
expectTypeOf(matcher).toEqualTypeOf<(shape: "x" | "y") => 1 | "two">();
|
||||
assert.equal(matcher("x"), 1);
|
||||
assert.equal(matcher("y"), "two");
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Factory contracts — calls that must not compile
|
||||
// ============================================================================
|
||||
@@ -341,8 +305,10 @@ test("getMatcher factory rejects patterns outside its contract", () => {
|
||||
// @ts-expect-error `c` is not part of the universe `"a" | "b"`
|
||||
c: () => 3,
|
||||
});
|
||||
// @ts-expect-error a gap without `_` is not exhaustive
|
||||
// @ts-expect-error a gap without a `_` fallback is not exhaustive
|
||||
factory({ a: () => 1 });
|
||||
// @ts-expect-error a `string` fallback does not fit the `number` handler
|
||||
factory({ a: () => 1 }, () => "x");
|
||||
});
|
||||
|
||||
test("getMatcherW factory rejects patterns outside its contract", () => {
|
||||
@@ -350,16 +316,31 @@ test("getMatcherW factory rejects patterns outside its contract", () => {
|
||||
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,
|
||||
// @ts-expect-error `z` is not part of the universe `"x" | "y"`
|
||||
z: () => 3 as const,
|
||||
});
|
||||
// @ts-expect-error a gap without `_` is not exhaustive
|
||||
// @ts-expect-error a gap without a `_` fallback is not exhaustive
|
||||
factory({ x: () => 1 as const });
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Dispatch — runtime behavior
|
||||
// ============================================================================
|
||||
|
||||
test("getMatcher: an unhandled shape throws without a fallback", () => {
|
||||
// Arrange — an open universe types its handler map as an index signature,
|
||||
// so the type system cannot prove the runtime map is exhaustive.
|
||||
const handlers: Record<string, () => number> = { a: () => 1 };
|
||||
const matcher = getMatcher<string>()(handlers);
|
||||
|
||||
// Act / Assert
|
||||
assert.equal(matcher("a"), 1);
|
||||
assert.throws(() => matcher("b"), /Unhandled shape: b/);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Autocomplete — the language server is the oracle, not the type system
|
||||
// ============================================================================
|
||||
@@ -374,11 +355,19 @@ 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[]> => {
|
||||
interface LabelsProbe {
|
||||
readonly name: string;
|
||||
readonly factory: "getMatcher" | "getMatcherW";
|
||||
readonly body: string;
|
||||
readonly tail?: string;
|
||||
}
|
||||
|
||||
const labelsFor = ({
|
||||
name,
|
||||
factory,
|
||||
body,
|
||||
tail = "",
|
||||
}: LabelsProbe): Promise<readonly string[]> => {
|
||||
const session = new LspSession(REPO_ROOT);
|
||||
const target: CompletionTarget = {
|
||||
file: `src/__autocomplete_${name}.ts`,
|
||||
@@ -386,7 +375,7 @@ const labelsFor = (
|
||||
`import { ${factory} } from "./index.ts";`,
|
||||
`const m = ${factory}<${UNIVERSE}>()({`,
|
||||
body,
|
||||
"});",
|
||||
`}${tail});`,
|
||||
"",
|
||||
].join("\n"),
|
||||
};
|
||||
@@ -396,16 +385,20 @@ const labelsFor = (
|
||||
.finally(() => session.close());
|
||||
};
|
||||
|
||||
test("autocomplete: an exhaustive pattern requires the universe, `_` optional", () => {
|
||||
test("autocomplete: an exhaustive pattern requires the universe", () => {
|
||||
// Arrange
|
||||
const name = "getMatcher_fresh";
|
||||
|
||||
// Act
|
||||
const labels = labelsFor(name, "getMatcher", " /*COMPLETE*/");
|
||||
const labels = labelsFor({
|
||||
name,
|
||||
factory: "getMatcher",
|
||||
body: " /*COMPLETE*/",
|
||||
});
|
||||
|
||||
// Assert
|
||||
return labels.then((result) => {
|
||||
assert.deepEqual([...result], ["_?", "a", "b", "c"]);
|
||||
assert.deepEqual([...result], ["a", "b", "c"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -414,28 +407,29 @@ test("autocomplete: handled keys drop out of the popup", () => {
|
||||
const name = "getMatcher_after_key";
|
||||
|
||||
// Act
|
||||
const labels = labelsFor(
|
||||
const labels = labelsFor({
|
||||
name,
|
||||
"getMatcher",
|
||||
" a: () => 1,\n /*COMPLETE*/",
|
||||
);
|
||||
factory: "getMatcher",
|
||||
body: " a: () => 1,\n /*COMPLETE*/",
|
||||
});
|
||||
|
||||
// Assert
|
||||
return labels.then((result) => {
|
||||
assert.deepEqual([...result], ["_?", "b", "c"]);
|
||||
assert.deepEqual([...result], ["b", "c"]);
|
||||
});
|
||||
});
|
||||
|
||||
test("autocomplete: `_` makes the remaining keys optional", () => {
|
||||
test("autocomplete: a fallback makes the remaining keys optional", () => {
|
||||
// Arrange
|
||||
const name = "getMatcher_after_fallback";
|
||||
const name = "getMatcher_with_fallback";
|
||||
|
||||
// Act
|
||||
const labels = labelsFor(
|
||||
const labels = labelsFor({
|
||||
name,
|
||||
"getMatcher",
|
||||
" _: () => 0,\n /*COMPLETE*/",
|
||||
);
|
||||
factory: "getMatcher",
|
||||
body: " /*COMPLETE*/",
|
||||
tail: ", () => 0",
|
||||
});
|
||||
|
||||
// Assert
|
||||
return labels.then((result) => {
|
||||
@@ -443,15 +437,55 @@ test("autocomplete: `_` makes the remaining keys optional", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("autocomplete: with a fallback, handled keys stay optional", () => {
|
||||
// Arrange
|
||||
const name = "getMatcher_with_fallback_after_key";
|
||||
|
||||
// Act
|
||||
const labels = labelsFor({
|
||||
name,
|
||||
factory: "getMatcher",
|
||||
body: " a: () => 1,\n /*COMPLETE*/",
|
||||
tail: ", () => 0",
|
||||
});
|
||||
|
||||
// Assert
|
||||
return labels.then((result) => {
|
||||
assert.deepEqual([...result], ["b?", "c?"]);
|
||||
});
|
||||
});
|
||||
|
||||
test("autocomplete: `getMatcherW` offers the same popup as `getMatcher`", () => {
|
||||
// Arrange
|
||||
const name = "getMatcherW_fresh";
|
||||
|
||||
// Act
|
||||
const labels = labelsFor(name, "getMatcherW", " /*COMPLETE*/");
|
||||
const labels = labelsFor({
|
||||
name,
|
||||
factory: "getMatcherW",
|
||||
body: " /*COMPLETE*/",
|
||||
});
|
||||
|
||||
// Assert
|
||||
return labels.then((result) => {
|
||||
assert.deepEqual([...result], ["_?", "a", "b", "c"]);
|
||||
assert.deepEqual([...result], ["a", "b", "c"]);
|
||||
});
|
||||
});
|
||||
|
||||
test("autocomplete: `getMatcherW` also offers optional keys with a fallback", () => {
|
||||
// Arrange
|
||||
const name = "getMatcherW_with_fallback";
|
||||
|
||||
// Act
|
||||
const labels = labelsFor({
|
||||
name,
|
||||
factory: "getMatcherW",
|
||||
body: " /*COMPLETE*/",
|
||||
tail: ", () => 0",
|
||||
});
|
||||
|
||||
// Assert
|
||||
return labels.then((result) => {
|
||||
assert.deepEqual([...result], ["a?", "b?", "c?"]);
|
||||
});
|
||||
});
|
||||
+43
-31
@@ -1,4 +1,4 @@
|
||||
import type { ValueOf } from "type-fest";
|
||||
import type { Exact, ValueOf } from "type-fest";
|
||||
|
||||
type UnaryFn<T, R> = (shape: T) => R;
|
||||
|
||||
@@ -8,54 +8,66 @@ type PatternReturns<P> = ReturnType<
|
||||
Extract<ValueOf<P>, (...args: never[]) => unknown>
|
||||
>;
|
||||
|
||||
type Handlers<R, T extends string | number> = { [K in T]: UnaryFn<K, R> };
|
||||
type Handlers<T extends string | number, R> = { [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>;
|
||||
};
|
||||
// The fallback is a *second argument*, not a property of the handler map,
|
||||
// because its parameter is the remainder `Exclude<T, keyof Handled>` and TypeScript
|
||||
// fixes a property's contextual type before it infers its sibling keys. A later
|
||||
// argument, by contrast, is contextually typed from inference on an earlier
|
||||
// one, so the split is what makes the remainder expressible at all.
|
||||
// See development/library.md.
|
||||
type Fallback<T extends string | number, Handled, R> = UnaryFn<
|
||||
Exclude<T, keyof Handled>,
|
||||
R
|
||||
>;
|
||||
|
||||
// Completion form: T-keys required, `_` optional. Overload #1, because
|
||||
// TypeScript takes the *first* overload as the contextual type for the popup.
|
||||
type ExhaustiveLoose<R, T extends string | number> = Handlers<R, T> & {
|
||||
_?: UnaryFn<T, R>;
|
||||
};
|
||||
// TypeScript does not apply the excess-property check to a generic constraint,
|
||||
// so `Exact` restores it for the generic forms: a handler map can otherwise
|
||||
// carry keys outside `T`.
|
||||
|
||||
// oxlint-disable typescript/unified-signatures
|
||||
// Strict returns: one common `R`. Overload order is load-bearing:
|
||||
// #1 ExhaustiveLoose -> autocomplete `_?, a, b`
|
||||
// #2 Fallback -> accepts a partial pattern
|
||||
// #3 Handlers (last) -> "Property 'b' is missing" is the reported error
|
||||
// #1 Handlers (first) -> the exhaustive form and the autocomplete popup
|
||||
// #2 Fallback (last) -> accepts a partial handler map plus a fallback
|
||||
interface MatcherStrict<T extends string | number> {
|
||||
<R>(pattern: ExhaustiveLoose<R, T>): UnaryFn<T, R>;
|
||||
<R>(pattern: Fallback<R, T>): UnaryFn<T, R>;
|
||||
<R>(pattern: Handlers<R, T>): UnaryFn<T, R>;
|
||||
<R>(handlers: Handlers<T, R>): UnaryFn<T, R>;
|
||||
<R, Handled extends Exact<Partial<Handlers<T, R>>, Handled>>(
|
||||
handlers: Handled & Partial<Handlers<T, R>>,
|
||||
fallback: Fallback<T, Handled, R>,
|
||||
): UnaryFn<T, R>;
|
||||
}
|
||||
// oxlint-enable typescript/unified-signatures
|
||||
|
||||
// Widened returns: the union of every handler's return type. `P` is inferred
|
||||
// from the whole parameter, whose closed constraint supplies the
|
||||
// contextual/autocomplete type; the `keyof P` guard appended to each overload
|
||||
// rejects keys outside `T`/`_`.
|
||||
// from the whole handler map, whose closed constraint supplies the
|
||||
// contextual/autocomplete type.
|
||||
// oxlint-disable typescript/unified-signatures
|
||||
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),
|
||||
<P extends Exact<Handlers<T, unknown>, P>>(
|
||||
handlers: P,
|
||||
): UnaryFn<T, PatternReturns<P>>;
|
||||
<R, Handled extends Exact<Partial<Handlers<T, unknown>>, Handled>>(
|
||||
handlers: Handled,
|
||||
fallback: Fallback<T, Handled, R>,
|
||||
): UnaryFn<T, PatternReturns<Handled> | R>;
|
||||
}
|
||||
// oxlint-enable typescript/unified-signatures
|
||||
|
||||
type HandlerMap = Record<string | number, UnaryFn<never, unknown> | undefined>;
|
||||
|
||||
const dispatch =
|
||||
(pattern: HandlerMap) =>
|
||||
(handlers: HandlerMap, fallback?: UnaryFn<never, unknown>) =>
|
||||
(shape: string | number): unknown =>
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion typescript/no-unsafe-type-assertion
|
||||
(pattern[shape] ?? pattern["_"]!)(shape as never);
|
||||
(
|
||||
handlers[shape] ??
|
||||
fallback ??
|
||||
(() => {
|
||||
throw new Error(`Unhandled shape: ${shape}`);
|
||||
})
|
||||
)(
|
||||
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
|
||||
shape as never,
|
||||
);
|
||||
|
||||
export const getMatcher = <T extends string | number>(): MatcherStrict<T> =>
|
||||
dispatch;
|
||||
|
||||
Reference in new issue
Block a user