🔀 Merge chore/rename-primitive-union into main

This commit is contained in:
tmu committed 2026-09-22 09:58:28 +00:00
commit b81a8149d9
11 files changed
+102 -87

No files matched your search

+3
View File
@@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
- add `getTaggedUnionMatcher` / `getTaggedUnionMatcherW` for discriminated unions - add `getTaggedUnionMatcher` / `getTaggedUnionMatcherW` for discriminated unions
- rename the primitive matcher to primitive-union: `getMatcher` /
`getMatcherW` → `getPrimitiveUnionMatcher` / `getPrimitiveUnionMatcherW`, and
`src/primitive.ts` / `src/primitive.test.ts` → `src/primitive-union.*`
## [0.5.0] - 2026-09-21 ## [0.5.0] - 2026-09-21
+1 -1
View File
@@ -71,7 +71,7 @@ Every test pairs an `expectTypeOf(...)` with an `assert.*`; keep them together
assertion on the value dispatch passed, not only the type it inferred (see assertion on the value dispatch passed, not only the type it inferred (see
[development/testing.md § Handler arguments](./development/testing.md#handler-arguments)). [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-union.test.ts` for the matcher's popup) are the
exception — exception —
the language server, not the type system, is the oracle (see the language server, not the type system, is the oracle (see
[development/testing.md § Autocomplete](./development/testing.md#autocomplete)). [development/testing.md § Autocomplete](./development/testing.md#autocomplete)).
+5 -4
View File
@@ -24,7 +24,7 @@ v1.0:
☐ Document all exported types and functions ☐ Document all exported types and functions
☐ Add JSDoc for public APIs ☐ Add JSDoc for public APIs
☐ Test coverage meets threshold ☐ Test coverage meets threshold
☐ Achieve 100% branch coverage on `src/primitive.ts` ☐ Achieve 100% branch coverage on `src/primitive-union.ts`
☐ Achieve 100% branch coverage on `src/index.ts` ☐ Achieve 100% branch coverage on `src/index.ts`
Testing: Testing:
@@ -42,7 +42,7 @@ Testing:
☐ Type hole when using broad types like string as a universe @high ☐ Type hole when using broad types like string as a universe @high
☐ handlers cannot be exhaustive, yet it is possible to not have a fallback ☐ handlers cannot be exhaustive, yet it is possible to not have a fallback
☐ one test even exploits this, I think for a negative test ☐ one test even exploits this, I think for a negative test
☐ does the same type hole exist for tagged-union matchers or primitive matchers only? ☐ does the same type hole exist for tagged-union matchers or primitive-union matchers only?
Matcher: Matcher:
✔ Clean up: adopt the 3-overload matcher (`src/prototype-ac2.ts`) and delete the prototypes @high @done ✔ Clean up: adopt the 3-overload matcher (`src/prototype-ac2.ts`) and delete the prototypes @high @done
@@ -55,10 +55,11 @@ Matcher:
✔ Implement matcher with similar API like matcher from primitive.ts @high @done ✔ Implement matcher with similar API like matcher from primitive.ts @high @done
→ `getTaggedUnionMatcher` / `getTaggedUnionMatcherW`, curried on the discriminant key → `getTaggedUnionMatcher` / `getTaggedUnionMatcherW`, curried on the discriminant key
→ fallback is the second argument; `_` removed → fallback is the second argument; `_` removed
☐ Rename `primitive` to `primitive-union` and `getMatcher` to `getPrimitiveMatcher` @medium ✔ Rename `primitive` to `primitive-union` and `getMatcher` to `getPrimitiveMatcher` @medium @done
→ `src/primitive.ts` / `src/primitive.test.ts` → `primitive-union.*` → `src/primitive.ts` / `src/primitive.test.ts` → `primitive-union.*`
→ `getMatcherW` → `getPrimitiveMatcherW` for symmetry with the tagged-union pair → `getMatcherW` → `getPrimitiveMatcherW` for symmetry with the tagged-union pair
→ update `src/index.ts`, `development/library.md` and any README references → update `src/index.ts`, `development/library.md` and any README references
→ shipped as `getPrimitiveUnionMatcher` / `getPrimitiveUnionMatcherW`; kept `Union` for symmetry with the tagged-union pair
☐ when using a union type as a property, the current behavior of tagged union matcher is ☐ when using a union type as a property, the current behavior of tagged union matcher is
to pass never to handler parameters to pass never to handler parameters
→ new matcher function needed or can be fixed in tagged union matcher → new matcher function needed or can be fixed in tagged union matcher
@@ -72,7 +73,7 @@ Enhancements:
✔ Are there other primitive types that should be supported in union patterns? (e.g. `bigint`, `symbol`) @medium @done ✔ Are there other primitive types that should be supported in union patterns? (e.g. `bigint`, `symbol`) @medium @done
→ added boolean, null and undefined; rejected `symbol` (compile-time brand, nothing at runtime) and `bigint` (not a property key) → added boolean, null and undefined; rejected `symbol` (compile-time brand, nothing at runtime) and `bigint` (not a property key)
☐ Allow boolean, null and undefined discriminant values in tagged-union patterns @medium ☐ Allow boolean, null and undefined discriminant values in tagged-union patterns @medium
→ needs the primitive matcher's `PatternKey` / `PatternParam` projection; see development/library.md § Tagged-union matcher → needs the primitive-union matcher's `PatternKey` / `PatternParam` projection; see development/library.md § Tagged-union matcher
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
+10 -10
View File
@@ -3,8 +3,8 @@
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).
The matchers are implemented in `src/primitive.ts` (`getMatcher` / The matchers are implemented in `src/primitive-union.ts` (`getPrimitiveUnionMatcher` /
`getMatcherW`) and `src/tagged-union.ts` (`getTaggedUnionMatcher` / `getPrimitiveUnionMatcherW`) and `src/tagged-union.ts` (`getTaggedUnionMatcher` /
`getTaggedUnionMatcherW`), re-exported from `src/index.ts`; the rest of the `getTaggedUnionMatcherW`), re-exported from `src/index.ts`; the rest of the
library is placeholder code. library is placeholder code.
@@ -16,8 +16,8 @@ A factory takes the universe and returns a builder; the builder takes a handler
map and an optional fallback: map and an optional fallback:
```ts ```ts
const matcher = getMatcher<"a" | "b">()({ a: (s) => …, b: (s) => … }); const matcher = getPrimitiveUnionMatcher<"a" | "b">()({ a: (s) => …, b: (s) => … });
const fallback = getMatcher<"a" | "b" | "c">()({ a: (s) => … }, (s) => …); const fallback = getPrimitiveUnionMatcher<"a" | "b" | "c">()({ a: (s) => … }, (s) => …);
``` ```
Exhaustive or fallback is decided **at the call site**, by whether the second Exhaustive or fallback is decided **at the call site**, by whether the second
@@ -25,8 +25,8 @@ argument is present. The fallback's parameter is the remainder
`Exclude<T, keyof Handled>`. Only the return-strictness axis remains, so there `Exclude<T, keyof Handled>`. Only the return-strictness axis remains, so there
are two factories: are two factories:
- `getMatcher` — one common `R`; the fallback must fit it; - `getPrimitiveUnionMatcher` — one common `R`; the fallback must fit it;
- `getMatcherW` — the union `PatternReturns<Handled> | R`. - `getPrimitiveUnionMatcherW` — the union `PatternReturns<Handled> | R`.
Each factory is two overloads whose order is load-bearing: Each factory is two overloads whose order is load-bearing:
@@ -118,7 +118,7 @@ use: `UnaryFn`, `PatternReturns`, `RedundantFallback`, `HandlerMap`.
#### Decision (2026-09) #### Decision (2026-09)
`getTaggedUnionMatcher` / `getTaggedUnionMatcherW` mirror the primitive pair `getTaggedUnionMatcher` / `getTaggedUnionMatcherW` mirror the primitive-union pair
with one extra curried step for the discriminant key: with one extra curried step for the discriminant key:
```ts ```ts
@@ -142,14 +142,14 @@ The key is a separate call because `K` is inferred from its literal argument and
#### Why #### Why
- **Same fallback/remainder machinery as the primitive matcher.** `HandledMembers` - **Same fallback/remainder machinery as the primitive-union matcher.** `HandledMembers`
maps the handled tags to their members and `Exclude<T, …>` is the fallback's maps the handled tags to their members and `Exclude<T, …>` is the fallback's
parameter; the redundant-fallback guard is the same F-bounded constraint. Only parameter; the redundant-fallback guard is the same F-bounded constraint. Only
the "universe" changes — `T`'s members instead of primitive values. the "universe" changes — `T`'s members instead of primitive values.
- **`T extends object`, not `Record<PropertyKey, unknown>`.** An `interface` has - **`T extends object`, not `Record<PropertyKey, unknown>`.** An `interface` has
no implicit index signature, so the `Record` constraint would reject no implicit index signature, so the `Record` constraint would reject
interface-based unions. The runtime reads the tag off `object` with one interface-based unions. The runtime reads the tag off `object` with one
assertion, the tagged twin of the primitive dispatch's `shape as string | number`. assertion, the tagged twin of the primitive-union dispatch's `shape as string | number`.
- **`MapTaggedUnion` distributes with `Extract`.** A duplicated tag yields a - **`MapTaggedUnion` distributes with `Extract`.** A duplicated tag yields a
union of members instead of dropping one. union of members instead of dropping one.
@@ -158,7 +158,7 @@ The key is a separate call because `K` is inferred from its literal argument and
- Tags are `string | number` only. A `boolean` / `null` / `undefined` - Tags are `string | number` only. A `boolean` / `null` / `undefined`
discriminant (`{ ok: true } | { ok: false }`) is rejected by `Discriminated`, discriminant (`{ ok: true } | { ok: false }`) is rejected by `Discriminated`,
because those values are not property keys; supporting them needs the because those values are not property keys; supporting them needs the
primitive matcher's `PatternKey` / `PatternParam` projection. primitive-union matcher's `PatternKey` / `PatternParam` projection.
- A member's tag must be unique across the union; two members with the same tag - A member's tag must be unique across the union; two members with the same tag
collapse to a union under one handler. collapse to a union under one handler.
+2 -2
View File
@@ -28,7 +28,7 @@ before the implementation.
- Runtime-first (classic red/green): it verifies the value, not the contract, - Runtime-first (classic red/green): it verifies the value, not the contract,
and the contract is the product. and the contract is the product.
- 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-union.test.ts`).
The runner is `node --test --strip-types "src/**/*.test.ts"` and the tiers are The runner is `node --test --strip-types "src/**/*.test.ts"` and the tiers are
in in
@@ -167,7 +167,7 @@ The script prints the labels the server offers at a `/*COMPLETE*/` marker inside
`<file>` (the marker is stripped before the document is sent). Its `LspSession` `<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 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 helper itself against inline documents, never the library's code — and by
`src/primitive.test.ts`, where the same probe asserts the matcher's popup; `src/primitive-union.test.ts`, where the same probe asserts the matcher's popup;
the CLI is for manual inspection. the CLI is for manual inspection.
#### Why #### Why
+1 -1
View File
@@ -107,7 +107,7 @@ 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 **at one site** is silenced with a A type-aware rule that false-positives **at one site** is silenced with a
source-level `oxlint-disable` directive (see `src/primitive.ts`). A rule that is source-level `oxlint-disable` directive (see `src/primitive-union.ts`). A rule that is
wrong for a whole **file class** is turned off in a `.oxlintrc.json` `overrides` wrong for a whole **file class** is turned off in a `.oxlintrc.json` `overrides`
entry instead — e.g. `typescript/no-floating-promises` (synchronous entry instead — e.g. `typescript/no-floating-promises` (synchronous
`expectTypeOf` reads as an unhandled promise) and `unicorn/no-null` (intentional `expectTypeOf` reads as an unhandled promise) and `unicorn/no-null` (intentional
+4 -1
View File
@@ -1,4 +1,7 @@
export { getMatcher, getMatcherW } from "./primitive.ts"; export {
getPrimitiveUnionMatcher,
getPrimitiveUnionMatcherW,
} from "./primitive-union.ts";
export { export {
getTaggedUnionMatcher, getTaggedUnionMatcher,
getTaggedUnionMatcherW, getTaggedUnionMatcherW,
+1 -1
View File
@@ -1,6 +1,6 @@
import type { ValueOf } from "type-fest"; import type { ValueOf } from "type-fest";
// The primitive and tagged-union matchers differ in their universe, but the // The primitive-union and tagged-union matchers differ in their universe, but the
// handler/fallback plumbing is identical; these are the shared pieces. The // handler/fallback plumbing is identical; these are the shared pieces. The
// boundary is deliberate: `Handlers`, `Fallback` and `MustBePartial` stay with // boundary is deliberate: `Handlers`, `Fallback` and `MustBePartial` stay with
// each matcher because they are built from its universe. See development/library.md. // each matcher because they are built from its universe. See development/library.md.
@@ -9,15 +9,18 @@ import {
LspSession, LspSession,
} from "#test-utils/lsp-completion.ts"; } from "#test-utils/lsp-completion.ts";
import { getMatcher, getMatcherW } from "./primitive.ts"; import {
getPrimitiveUnionMatcher,
getPrimitiveUnionMatcherW,
} from "./primitive-union.ts";
// ============================================================================ // ============================================================================
// API: getMatcher — ✔️ Exhaustive / ✔️ ReturnsStrict // API: getPrimitiveUnionMatcher — ✔️ Exhaustive / ✔️ ReturnsStrict
// ============================================================================ // ============================================================================
test("getMatcher: exhaustive pattern infers one common return type", () => { test("getPrimitiveUnionMatcher: exhaustive pattern infers one common return type", () => {
// Arrange // Arrange
const factory = getMatcher<"a" | "b">(); const factory = getPrimitiveUnionMatcher<"a" | "b">();
// Act // Act
const matcher = factory({ const matcher = factory({
@@ -42,9 +45,9 @@ test("getMatcher: exhaustive pattern infers one common return type", () => {
assert.equal(matcher("b"), 2); assert.equal(matcher("b"), 2);
}); });
test("getMatcher: dispatches on numeric literal keys", () => { test("getPrimitiveUnionMatcher: dispatches on numeric literal keys", () => {
// Arrange // Arrange
const factory = getMatcher<1 | 2>(); const factory = getPrimitiveUnionMatcher<1 | 2>();
// Act // Act
const matcher = factory({ const matcher = factory({
@@ -67,9 +70,9 @@ test("getMatcher: dispatches on numeric literal keys", () => {
assert.equal(matcher(2), 20); assert.equal(matcher(2), 20);
}); });
test("getMatcher: dispatches on mixed string and numeric keys", () => { test("getPrimitiveUnionMatcher: dispatches on mixed string and numeric keys", () => {
// Arrange // Arrange
const factory = getMatcher<"a" | "b" | 1 | 2>(); const factory = getPrimitiveUnionMatcher<"a" | "b" | 1 | 2>();
// Act // Act
const matcher = factory({ const matcher = factory({
@@ -103,9 +106,9 @@ 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", () => { test("getPrimitiveUnionMatcher: dispatches on boolean literals", () => {
// Arrange // Arrange
const factory = getMatcher<boolean>(); const factory = getPrimitiveUnionMatcher<boolean>();
// Act // Act
const matcher = factory({ const matcher = factory({
@@ -129,9 +132,9 @@ test("getMatcher: dispatches on boolean literals", () => {
assert.equal(matcher(false), 2); assert.equal(matcher(false), 2);
}); });
test("getMatcher: dispatches on null and undefined", () => { test("getPrimitiveUnionMatcher: dispatches on null and undefined", () => {
// Arrange // Arrange
const factory = getMatcher<null | undefined>(); const factory = getPrimitiveUnionMatcher<null | undefined>();
// Act // Act
const matcher = factory({ const matcher = factory({
@@ -154,12 +157,12 @@ test("getMatcher: dispatches on null and undefined", () => {
}); });
// ============================================================================ // ============================================================================
// API: getMatcher — ❌ Exhaustive (fallback) / ✔️ ReturnsStrict // API: getPrimitiveUnionMatcher — ❌ Exhaustive (fallback) / ✔️ ReturnsStrict
// ============================================================================ // ============================================================================
test("getMatcher: a `_` fallback receives the unhandled keys", () => { test("getPrimitiveUnionMatcher: a `_` fallback receives the unhandled keys", () => {
// Arrange // Arrange
const factory = getMatcher<"a" | "b" | "c">(); const factory = getPrimitiveUnionMatcher<"a" | "b" | "c">();
// Act // Act
const matcher = factory( const matcher = factory(
@@ -187,9 +190,9 @@ test("getMatcher: a `_` fallback receives the unhandled keys", () => {
assert.equal(matcher("c"), 2); assert.equal(matcher("c"), 2);
}); });
test("getMatcher: a `_` fallback routes mixed string and numeric gaps", () => { test("getPrimitiveUnionMatcher: a `_` fallback routes mixed string and numeric gaps", () => {
// Arrange // Arrange
const factory = getMatcher<"a" | "b" | 1 | 2>(); const factory = getPrimitiveUnionMatcher<"a" | "b" | 1 | 2>();
// Act // Act
const matcher = factory( const matcher = factory(
@@ -222,9 +225,11 @@ 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", () => { test("getPrimitiveUnionMatcher: a `_` fallback receives unhandled boolean and nullish keys", () => {
// Arrange // Arrange
const factory = getMatcher<"a" | true | false | null | undefined>(); const factory = getPrimitiveUnionMatcher<
"a" | true | false | null | undefined
>();
// Act // Act
const matcher = factory( const matcher = factory(
@@ -261,12 +266,12 @@ test("getMatcher: a `_` fallback receives unhandled boolean and nullish keys", (
}); });
// ============================================================================ // ============================================================================
// API: getMatcherW — ✔️ Exhaustive / ❌ ReturnsStrict // API: getPrimitiveUnionMatcherW — ✔️ Exhaustive / ❌ ReturnsStrict
// ============================================================================ // ============================================================================
test("getMatcherW: exhaustive pattern widens to the union of handler returns", () => { test("getPrimitiveUnionMatcherW: exhaustive pattern widens to the union of handler returns", () => {
// Arrange // Arrange
const factory = getMatcherW<"x" | "y">(); const factory = getPrimitiveUnionMatcherW<"x" | "y">();
// Act // Act
const matcher = factory({ const matcher = factory({
@@ -291,9 +296,9 @@ test("getMatcherW: exhaustive pattern widens to the union of handler returns", (
assert.equal(matcher("y"), "two"); assert.equal(matcher("y"), "two");
}); });
test("getMatcherW: dispatches on mixed string and numeric keys", () => { test("getPrimitiveUnionMatcherW: dispatches on mixed string and numeric keys", () => {
// Arrange // Arrange
const factory = getMatcherW<"a" | "b" | 1 | 2>(); const factory = getPrimitiveUnionMatcherW<"a" | "b" | 1 | 2>();
// Act // Act
const matcher = factory({ const matcher = factory({
@@ -329,9 +334,9 @@ 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", () => { test("getPrimitiveUnionMatcherW: exhaustive boolean and nullish widen to the union", () => {
// Arrange // Arrange
const factory = getMatcherW<boolean | null | undefined>(); const factory = getPrimitiveUnionMatcherW<boolean | null | undefined>();
// Act // Act
const matcher = factory({ const matcher = factory({
@@ -368,12 +373,12 @@ test("getMatcherW: exhaustive boolean and nullish widen to the union", () => {
}); });
// ============================================================================ // ============================================================================
// API: getMatcherW — ❌ Exhaustive (fallback) / ❌ ReturnsStrict // API: getPrimitiveUnionMatcherW — ❌ Exhaustive (fallback) / ❌ ReturnsStrict
// ============================================================================ // ============================================================================
test("getMatcherW: a `_` fallback widens gaps into the union", () => { test("getPrimitiveUnionMatcherW: a `_` fallback widens gaps into the union", () => {
// Arrange // Arrange
const factory = getMatcherW<"x" | "y" | "z">(); const factory = getPrimitiveUnionMatcherW<"x" | "y" | "z">();
// Act // Act
const matcher = factory( const matcher = factory(
@@ -404,9 +409,9 @@ test("getMatcherW: a `_` fallback widens gaps into the union", () => {
assert.equal(matcher("z"), "z"); assert.equal(matcher("z"), "z");
}); });
test("getMatcherW: a `_` fallback widens mixed string and numeric returns", () => { test("getPrimitiveUnionMatcherW: a `_` fallback widens mixed string and numeric returns", () => {
// Arrange // Arrange
const factory = getMatcherW<"a" | "b" | 1 | 2>(); const factory = getPrimitiveUnionMatcherW<"a" | "b" | 1 | 2>();
// Act // Act
const matcher = factory( const matcher = factory(
@@ -447,9 +452,9 @@ test("getMatcherW: a `_` fallback widens mixed string and numeric returns", () =
// Factory contracts — calls that must not compile // Factory contracts — calls that must not compile
// ============================================================================ // ============================================================================
test("getMatcher factory rejects patterns outside its contract", () => { test("getPrimitiveUnionMatcher factory rejects patterns outside its contract", () => {
// Arrange // Arrange
const factory = getMatcher<"a" | "b">(); const factory = getPrimitiveUnionMatcher<"a" | "b">();
// Act / Assert — the calls below must not compile. // Act / Assert — the calls below must not compile.
// `Parameters<typeof factory>[0]` resolves only the *last* overload, so it // `Parameters<typeof factory>[0]` resolves only the *last* overload, so it
@@ -469,9 +474,9 @@ test("getMatcher factory rejects patterns outside its contract", () => {
factory({ a: () => 1, b: () => 2 }, () => 0); factory({ a: () => 1, b: () => 2 }, () => 0);
}); });
test("getMatcher factory rejects non-exhaustive boolean and nullish maps", () => { test("getPrimitiveUnionMatcher factory rejects non-exhaustive boolean and nullish maps", () => {
// Arrange // Arrange
const factory = getMatcher<boolean | null | undefined>(); const factory = getPrimitiveUnionMatcher<boolean | null | undefined>();
// Act / Assert — the calls below must not compile // Act / Assert — the calls below must not compile
// @ts-expect-error only `true` is handled; `false`, `null`, `undefined` are not // @ts-expect-error only `true` is handled; `false`, `null`, `undefined` are not
@@ -490,9 +495,9 @@ test("getMatcher factory rejects non-exhaustive boolean and nullish maps", () =>
); );
}); });
test("getMatcher: an open universe keeps the fallback's remainder open", () => { test("getPrimitiveUnionMatcher: an open universe keeps the fallback's remainder open", () => {
// Arrange // Arrange
const factory = getMatcher<string>(); const factory = getPrimitiveUnionMatcher<string>();
// Act // Act
const matcher = factory({ a: () => 1 }, (s) => { const matcher = factory({ a: () => 1 }, (s) => {
@@ -512,9 +517,9 @@ test("getMatcher: an open universe keeps the fallback's remainder open", () => {
assert.equal(matcher("b"), 2); assert.equal(matcher("b"), 2);
}); });
test("getMatcherW factory rejects patterns outside its contract", () => { test("getPrimitiveUnionMatcherW factory rejects patterns outside its contract", () => {
// Arrange // Arrange
const factory = getMatcherW<"x" | "y">(); const factory = getPrimitiveUnionMatcherW<"x" | "y">();
// Act / Assert — the calls below must not compile // Act / Assert — the calls below must not compile
factory({ factory({
@@ -533,7 +538,7 @@ test("getMatcherW factory rejects patterns outside its contract", () => {
// Dispatch — runtime behavior // Dispatch — runtime behavior
// ============================================================================ // ============================================================================
test("getMatcher: an unhandled shape throws without a fallback", () => { test("getPrimitiveUnionMatcher: an unhandled shape throws without a fallback", () => {
// Arrange — an open universe types its handler map as an index signature, // 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, (shape: unknown) => number> = { const handlers: Record<string, (shape: unknown) => number> = {
@@ -543,14 +548,14 @@ test("getMatcher: an unhandled shape throws without a fallback", () => {
return 1; return 1;
}, },
}; };
const matcher = getMatcher<string>()(handlers); const matcher = getPrimitiveUnionMatcher<string>()(handlers);
// Act / Assert // Act / Assert
assert.equal(matcher("a"), 1); assert.equal(matcher("a"), 1);
assert.throws(() => matcher("b"), /Unhandled shape: b/); assert.throws(() => matcher("b"), /Unhandled shape: b/);
}); });
test("getMatcher: an unhandled boolean shape throws without a fallback", () => { test("getPrimitiveUnionMatcher: an unhandled boolean shape throws without a fallback", () => {
// Arrange — an `string | boolean` universe widens its handler map to an // Arrange — an `string | boolean` universe widens its handler map to an
// index signature, so the runtime map's exhaustiveness is not provable. // index signature, so the runtime map's exhaustiveness is not provable.
const handlers: Record<string, (shape: unknown) => number> = { const handlers: Record<string, (shape: unknown) => number> = {
@@ -561,7 +566,7 @@ test("getMatcher: an unhandled boolean shape throws without a fallback", () => {
return 1; return 1;
}, },
}; };
const matcher = getMatcher<string | boolean>()(handlers); const matcher = getPrimitiveUnionMatcher<string | boolean>()(handlers);
// Act / Assert // Act / Assert
assert.equal(matcher(true), 1); assert.equal(matcher(true), 1);
@@ -584,7 +589,7 @@ const UNIVERSE = `"a" | "b" | "c"`;
// documents, project membership), so they pass in any order. // documents, project membership), so they pass in any order.
interface LabelsProbe { interface LabelsProbe {
readonly name: string; readonly name: string;
readonly factory: "getMatcher" | "getMatcherW"; readonly factory: "getPrimitiveUnionMatcher" | "getPrimitiveUnionMatcherW";
readonly body: string; readonly body: string;
readonly universe?: string; readonly universe?: string;
readonly tail?: string; readonly tail?: string;
@@ -616,12 +621,12 @@ const labelsFor = ({
test("autocomplete: an exhaustive pattern requires the universe", () => { test("autocomplete: an exhaustive pattern requires the universe", () => {
// Arrange // Arrange
const name = "getMatcher_fresh"; const name = "getPrimitiveUnionMatcher_fresh";
// Act // Act
const labels = labelsFor({ const labels = labelsFor({
name, name,
factory: "getMatcher", factory: "getPrimitiveUnionMatcher",
body: " /*COMPLETE*/", body: " /*COMPLETE*/",
}); });
@@ -633,12 +638,12 @@ test("autocomplete: an exhaustive pattern requires the universe", () => {
test("autocomplete: handled keys drop out of the popup", () => { test("autocomplete: handled keys drop out of the popup", () => {
// Arrange // Arrange
const name = "getMatcher_after_key"; const name = "getPrimitiveUnionMatcher_after_key";
// Act // Act
const labels = labelsFor({ const labels = labelsFor({
name, name,
factory: "getMatcher", factory: "getPrimitiveUnionMatcher",
body: " a: () => 1,\n /*COMPLETE*/", body: " a: () => 1,\n /*COMPLETE*/",
}); });
@@ -650,12 +655,12 @@ test("autocomplete: handled keys drop out of the popup", () => {
test("autocomplete: a fallback makes the remaining keys optional", () => { test("autocomplete: a fallback makes the remaining keys optional", () => {
// Arrange // Arrange
const name = "getMatcher_with_fallback"; const name = "getPrimitiveUnionMatcher_with_fallback";
// Act // Act
const labels = labelsFor({ const labels = labelsFor({
name, name,
factory: "getMatcher", factory: "getPrimitiveUnionMatcher",
body: " /*COMPLETE*/", body: " /*COMPLETE*/",
tail: ", () => 0", tail: ", () => 0",
}); });
@@ -668,12 +673,12 @@ test("autocomplete: a fallback makes the remaining keys optional", () => {
test("autocomplete: with a fallback, handled keys stay optional", () => { test("autocomplete: with a fallback, handled keys stay optional", () => {
// Arrange // Arrange
const name = "getMatcher_with_fallback_after_key"; const name = "getPrimitiveUnionMatcher_with_fallback_after_key";
// Act // Act
const labels = labelsFor({ const labels = labelsFor({
name, name,
factory: "getMatcher", factory: "getPrimitiveUnionMatcher",
body: " a: () => 1,\n /*COMPLETE*/", body: " a: () => 1,\n /*COMPLETE*/",
tail: ", () => 0", tail: ", () => 0",
}); });
@@ -684,14 +689,14 @@ test("autocomplete: with a fallback, handled keys stay optional", () => {
}); });
}); });
test("autocomplete: `getMatcherW` offers the same popup as `getMatcher`", () => { test("autocomplete: `getPrimitiveUnionMatcherW` offers the same popup as `getPrimitiveUnionMatcher`", () => {
// Arrange // Arrange
const name = "getMatcherW_fresh"; const name = "getPrimitiveUnionMatcherW_fresh";
// Act // Act
const labels = labelsFor({ const labels = labelsFor({
name, name,
factory: "getMatcherW", factory: "getPrimitiveUnionMatcherW",
body: " /*COMPLETE*/", body: " /*COMPLETE*/",
}); });
@@ -701,14 +706,14 @@ test("autocomplete: `getMatcherW` offers the same popup as `getMatcher`", () =>
}); });
}); });
test("autocomplete: `getMatcherW` also offers optional keys with a fallback", () => { test("autocomplete: `getPrimitiveUnionMatcherW` also offers optional keys with a fallback", () => {
// Arrange // Arrange
const name = "getMatcherW_with_fallback"; const name = "getPrimitiveUnionMatcherW_with_fallback";
// Act // Act
const labels = labelsFor({ const labels = labelsFor({
name, name,
factory: "getMatcherW", factory: "getPrimitiveUnionMatcherW",
body: " /*COMPLETE*/", body: " /*COMPLETE*/",
tail: ", () => 0", tail: ", () => 0",
}); });
@@ -721,12 +726,12 @@ test("autocomplete: `getMatcherW` also offers optional keys with a fallback", ()
test("autocomplete: boolean and nullish keys are offered by name", () => { test("autocomplete: boolean and nullish keys are offered by name", () => {
// Arrange // Arrange
const name = "getMatcher_boolean_nullish"; const name = "getPrimitiveUnionMatcher_boolean_nullish";
// Act // Act
const labels = labelsFor({ const labels = labelsFor({
name, name,
factory: "getMatcher", factory: "getPrimitiveUnionMatcher",
universe: "boolean | null | undefined", universe: "boolean | null | undefined",
body: " /*COMPLETE*/", body: " /*COMPLETE*/",
}); });
@@ -739,12 +744,12 @@ test("autocomplete: boolean and nullish keys are offered by name", () => {
test("autocomplete: a handled boolean literal drops out of the popup", () => { test("autocomplete: a handled boolean literal drops out of the popup", () => {
// Arrange // Arrange
const name = "getMatcher_boolean_after_key"; const name = "getPrimitiveUnionMatcher_boolean_after_key";
// Act // Act
const labels = labelsFor({ const labels = labelsFor({
name, name,
factory: "getMatcher", factory: "getPrimitiveUnionMatcher",
universe: "boolean | null | undefined", universe: "boolean | null | undefined",
body: " true: () => 1,\n /*COMPLETE*/", body: " true: () => 1,\n /*COMPLETE*/",
}); });
+6 -3
View File
@@ -123,6 +123,9 @@ const dispatch =
shape as never, shape as never,
); );
export const getMatcher = <T extends Matchable>(): MatcherStrict<T> => dispatch; export const getPrimitiveUnionMatcher = <
export const getMatcherW = <T extends Matchable>(): MatcherWidening<T> => T extends Matchable,
dispatch; >(): MatcherStrict<T> => dispatch;
export const getPrimitiveUnionMatcherW = <
T extends Matchable,
>(): MatcherWidening<T> => dispatch;
+2 -2
View File
@@ -36,7 +36,7 @@ type Handlers<T extends object, K extends keyof T, R> = {
// The members `Handled` covers. Mapping over `Tags` keeps every index within // The members `Handled` covers. Mapping over `Tags` keeps every index within
// `MapTaggedUnion`'s keys, and the conditional drops a stray key outside `T` so // `MapTaggedUnion`'s keys, and the conditional drops a stray key outside `T` so
// it cannot widen the remainder. The remainder is `Exclude<T, …>`, mirroring // it cannot widen the remainder. The remainder is `Exclude<T, …>`, mirroring
// the primitive matcher's `Exclude<T, PatternParam<keyof Handled>>`. // the primitive-union matcher's `Exclude<T, PatternParam<keyof Handled>>`.
type HandledMembers<T extends object, K extends keyof T, Handled> = { type HandledMembers<T extends object, K extends keyof T, Handled> = {
[V in Tags<T, K>]: V extends keyof Handled [V in Tags<T, K>]: V extends keyof Handled
? MapTaggedUnion<T, K>[V] ? MapTaggedUnion<T, K>[V]
@@ -52,7 +52,7 @@ type Fallback<T extends object, K extends keyof T, Handled, R> = UnaryFn<
// A fallback is redundant once the handler map covers every tag of `T`. Folded // A fallback is redundant once the handler map covers every tag of `T`. Folded
// into `Handled`'s own (self-referential) constraint so it is checked *after* // into `Handled`'s own (self-referential) constraint so it is checked *after*
// inference; see the primitive matcher for why a conditional in the fallback's // inference; see the primitive-union matcher for why a conditional in the fallback's
// parameter is evaluated too early. // parameter is evaluated too early.
type MustBePartial<T extends object, K extends keyof T, Handled> = type MustBePartial<T extends object, K extends keyof T, Handled> =
Tags<T, K> extends keyof Handled ? RedundantFallback : unknown; Tags<T, K> extends keyof Handled ? RedundantFallback : unknown;