diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c88904..a41796b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1fcd42e..ceb24c9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 [development/testing.md § Handler arguments](./development/testing.md#handler-arguments)). The autocomplete tests (`src/util/__tests__/lsp-completion.test.ts` for the -helper, `src/primitive.test.ts` for the matcher's popup) are the +helper, `src/primitive-union.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)). diff --git a/development/library.md b/development/library.md index 0624bb7..4b0698a 100644 --- a/development/library.md +++ b/development/library.md @@ -3,8 +3,8 @@ The type-level design of the public API and the limitations it carries. The user-facing reference is [README § API](../README.md#api). -The matchers are implemented in `src/primitive.ts` (`getMatcher` / -`getMatcherW`) and `src/tagged-union.ts` (`getTaggedUnionMatcher` / +The matchers are implemented in `src/primitive-union.ts` (`getPrimitiveUnionMatcher` / +`getPrimitiveUnionMatcherW`) and `src/tagged-union.ts` (`getTaggedUnionMatcher` / `getTaggedUnionMatcherW`), re-exported from `src/index.ts`; the rest of the 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: ```ts -const matcher = getMatcher<"a" | "b">()({ a: (s) => …, b: (s) => … }); -const fallback = getMatcher<"a" | "b" | "c">()({ a: (s) => … }, (s) => …); +const matcher = getPrimitiveUnionMatcher<"a" | "b">()({ a: (s) => …, b: (s) => … }); +const fallback = getPrimitiveUnionMatcher<"a" | "b" | "c">()({ a: (s) => … }, (s) => …); ``` 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`. Only the return-strictness axis remains, so there are two factories: -- `getMatcher` — one common `R`; the fallback must fit it; -- `getMatcherW` — the union `PatternReturns | R`. +- `getPrimitiveUnionMatcher` — one common `R`; the fallback must fit it; +- `getPrimitiveUnionMatcherW` — the union `PatternReturns | R`. Each factory is two overloads whose order is load-bearing: @@ -118,7 +118,7 @@ use: `UnaryFn`, `PatternReturns`, `RedundantFallback`, `HandlerMap`. #### 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: ```ts @@ -142,14 +142,14 @@ The key is a separate call because `K` is inferred from its literal argument and #### 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` is the fallback's parameter; the redundant-fallback guard is the same F-bounded constraint. Only the "universe" changes — `T`'s members instead of primitive values. - **`T extends object`, not `Record`.** An `interface` has no implicit index signature, so the `Record` constraint would reject interface-based unions. The runtime reads the tag off `object` with one - assertion, the tagged twin of the primitive dispatch's `shape as string | number`. + assertion, the tagged twin of the primitive-union dispatch's `shape as string | number`. - **`MapTaggedUnion` distributes with `Extract`.** A duplicated tag yields a 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` discriminant (`{ ok: true } | { ok: false }`) is rejected by `Discriminated`, because those values are not property keys; supporting them needs the - primitive matcher's `PatternKey` / `PatternParam` projection. + primitive-union matcher's `PatternKey` / `PatternParam` projection. - A member's tag must be unique across the union; two members with the same tag collapse to a union under one handler. diff --git a/development/testing.md b/development/testing.md index bcbf33d..2eee22e 100644 --- a/development/testing.md +++ b/development/testing.md @@ -28,7 +28,7 @@ before the implementation. - Runtime-first (classic red/green): it verifies the value, not the contract, and the contract is the product. - 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 in @@ -167,7 +167,7 @@ The script prints the labels the server offers at a `/*COMPLETE*/` marker inside `` (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; +`src/primitive-union.test.ts`, where the same probe asserts the matcher's popup; the CLI is for manual inspection. #### Why diff --git a/development/tooling.md b/development/tooling.md index 7bc39bd..91bb8b7 100644 --- a/development/tooling.md +++ b/development/tooling.md @@ -107,7 +107,7 @@ Type-aware oxlint is enabled via `options.typeAware: true` in `.oxlintrc.json` #### Decision (2026-09) 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` entry instead — e.g. `typescript/no-floating-promises` (synchronous `expectTypeOf` reads as an unhandled promise) and `unicorn/no-null` (intentional diff --git a/src/index.ts b/src/index.ts index 442ad4a..608736e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,7 @@ -export { getMatcher, getMatcherW } from "./primitive.ts"; +export { + getPrimitiveUnionMatcher, + getPrimitiveUnionMatcherW, +} from "./primitive-union.ts"; export { getTaggedUnionMatcher, getTaggedUnionMatcherW, diff --git a/src/matcher-shared.ts b/src/matcher-shared.ts index f1fa22e..767c543 100644 --- a/src/matcher-shared.ts +++ b/src/matcher-shared.ts @@ -1,6 +1,6 @@ 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 // boundary is deliberate: `Handlers`, `Fallback` and `MustBePartial` stay with // each matcher because they are built from its universe. See development/library.md. diff --git a/src/primitive.test.ts b/src/primitive-union.test.ts similarity index 81% rename from src/primitive.test.ts rename to src/primitive-union.test.ts index e0f1b4b..f2235a7 100644 --- a/src/primitive.test.ts +++ b/src/primitive-union.test.ts @@ -9,15 +9,18 @@ import { LspSession, } 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 - const factory = getMatcher<"a" | "b">(); + const factory = getPrimitiveUnionMatcher<"a" | "b">(); // Act const matcher = factory({ @@ -42,9 +45,9 @@ test("getMatcher: exhaustive pattern infers one common return type", () => { assert.equal(matcher("b"), 2); }); -test("getMatcher: dispatches on numeric literal keys", () => { +test("getPrimitiveUnionMatcher: dispatches on numeric literal keys", () => { // Arrange - const factory = getMatcher<1 | 2>(); + const factory = getPrimitiveUnionMatcher<1 | 2>(); // Act const matcher = factory({ @@ -67,9 +70,9 @@ test("getMatcher: dispatches on numeric literal keys", () => { assert.equal(matcher(2), 20); }); -test("getMatcher: dispatches on mixed string and numeric keys", () => { +test("getPrimitiveUnionMatcher: dispatches on mixed string and numeric keys", () => { // Arrange - const factory = getMatcher<"a" | "b" | 1 | 2>(); + const factory = getPrimitiveUnionMatcher<"a" | "b" | 1 | 2>(); // Act const matcher = factory({ @@ -103,9 +106,9 @@ test("getMatcher: dispatches on mixed string and numeric keys", () => { assert.equal(matcher(2), 20); }); -test("getMatcher: dispatches on boolean literals", () => { +test("getPrimitiveUnionMatcher: dispatches on boolean literals", () => { // Arrange - const factory = getMatcher(); + const factory = getPrimitiveUnionMatcher(); // Act const matcher = factory({ @@ -129,9 +132,9 @@ test("getMatcher: dispatches on boolean literals", () => { assert.equal(matcher(false), 2); }); -test("getMatcher: dispatches on null and undefined", () => { +test("getPrimitiveUnionMatcher: dispatches on null and undefined", () => { // Arrange - const factory = getMatcher(); + const factory = getPrimitiveUnionMatcher(); // Act 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 - const factory = getMatcher<"a" | "b" | "c">(); + const factory = getPrimitiveUnionMatcher<"a" | "b" | "c">(); // Act const matcher = factory( @@ -187,9 +190,9 @@ test("getMatcher: a `_` fallback receives the unhandled keys", () => { 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 - const factory = getMatcher<"a" | "b" | 1 | 2>(); + const factory = getPrimitiveUnionMatcher<"a" | "b" | 1 | 2>(); // Act const matcher = factory( @@ -222,9 +225,11 @@ test("getMatcher: a `_` fallback routes mixed string and numeric gaps", () => { 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 - const factory = getMatcher<"a" | true | false | null | undefined>(); + const factory = getPrimitiveUnionMatcher< + "a" | true | false | null | undefined + >(); // Act 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 - const factory = getMatcherW<"x" | "y">(); + const factory = getPrimitiveUnionMatcherW<"x" | "y">(); // Act const matcher = factory({ @@ -291,9 +296,9 @@ test("getMatcherW: exhaustive pattern widens to the union of handler returns", ( assert.equal(matcher("y"), "two"); }); -test("getMatcherW: dispatches on mixed string and numeric keys", () => { +test("getPrimitiveUnionMatcherW: dispatches on mixed string and numeric keys", () => { // Arrange - const factory = getMatcherW<"a" | "b" | 1 | 2>(); + const factory = getPrimitiveUnionMatcherW<"a" | "b" | 1 | 2>(); // Act const matcher = factory({ @@ -329,9 +334,9 @@ test("getMatcherW: dispatches on mixed string and numeric keys", () => { 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 - const factory = getMatcherW(); + const factory = getPrimitiveUnionMatcherW(); // Act 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 - const factory = getMatcherW<"x" | "y" | "z">(); + const factory = getPrimitiveUnionMatcherW<"x" | "y" | "z">(); // Act const matcher = factory( @@ -404,9 +409,9 @@ test("getMatcherW: a `_` fallback widens gaps into the union", () => { 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 - const factory = getMatcherW<"a" | "b" | 1 | 2>(); + const factory = getPrimitiveUnionMatcherW<"a" | "b" | 1 | 2>(); // Act const matcher = factory( @@ -447,9 +452,9 @@ test("getMatcherW: a `_` fallback widens mixed string and numeric returns", () = // Factory contracts — calls that must not compile // ============================================================================ -test("getMatcher factory rejects patterns outside its contract", () => { +test("getPrimitiveUnionMatcher factory rejects patterns outside its contract", () => { // Arrange - const factory = getMatcher<"a" | "b">(); + const factory = getPrimitiveUnionMatcher<"a" | "b">(); // Act / Assert — the calls below must not compile. // `Parameters[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); }); -test("getMatcher factory rejects non-exhaustive boolean and nullish maps", () => { +test("getPrimitiveUnionMatcher factory rejects non-exhaustive boolean and nullish maps", () => { // Arrange - const factory = getMatcher(); + const factory = getPrimitiveUnionMatcher(); // Act / Assert — the calls below must not compile // @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 - const factory = getMatcher(); + const factory = getPrimitiveUnionMatcher(); // Act 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); }); -test("getMatcherW factory rejects patterns outside its contract", () => { +test("getPrimitiveUnionMatcherW factory rejects patterns outside its contract", () => { // Arrange - const factory = getMatcherW<"x" | "y">(); + const factory = getPrimitiveUnionMatcherW<"x" | "y">(); // Act / Assert — the calls below must not compile factory({ @@ -533,7 +538,7 @@ test("getMatcherW factory rejects patterns outside its contract", () => { // 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, // so the type system cannot prove the runtime map is exhaustive. const handlers: Record number> = { @@ -543,14 +548,14 @@ test("getMatcher: an unhandled shape throws without a fallback", () => { return 1; }, }; - const matcher = getMatcher()(handlers); + const matcher = getPrimitiveUnionMatcher()(handlers); // Act / Assert assert.equal(matcher("a"), 1); assert.throws(() => matcher("b"), /Unhandled shape: b/); }); -test("getMatcher: an unhandled boolean shape throws without a fallback", () => { +test("getPrimitiveUnionMatcher: an unhandled boolean shape throws without a fallback", () => { // Arrange — an `string | boolean` universe widens its handler map to an // index signature, so the runtime map's exhaustiveness is not provable. const handlers: Record number> = { @@ -561,7 +566,7 @@ test("getMatcher: an unhandled boolean shape throws without a fallback", () => { return 1; }, }; - const matcher = getMatcher()(handlers); + const matcher = getPrimitiveUnionMatcher()(handlers); // Act / Assert assert.equal(matcher(true), 1); @@ -584,7 +589,7 @@ const UNIVERSE = `"a" | "b" | "c"`; // documents, project membership), so they pass in any order. interface LabelsProbe { readonly name: string; - readonly factory: "getMatcher" | "getMatcherW"; + readonly factory: "getPrimitiveUnionMatcher" | "getPrimitiveUnionMatcherW"; readonly body: string; readonly universe?: string; readonly tail?: string; @@ -616,12 +621,12 @@ const labelsFor = ({ test("autocomplete: an exhaustive pattern requires the universe", () => { // Arrange - const name = "getMatcher_fresh"; + const name = "getPrimitiveUnionMatcher_fresh"; // Act const labels = labelsFor({ name, - factory: "getMatcher", + factory: "getPrimitiveUnionMatcher", body: " /*COMPLETE*/", }); @@ -633,12 +638,12 @@ test("autocomplete: an exhaustive pattern requires the universe", () => { test("autocomplete: handled keys drop out of the popup", () => { // Arrange - const name = "getMatcher_after_key"; + const name = "getPrimitiveUnionMatcher_after_key"; // Act const labels = labelsFor({ name, - factory: "getMatcher", + factory: "getPrimitiveUnionMatcher", 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", () => { // Arrange - const name = "getMatcher_with_fallback"; + const name = "getPrimitiveUnionMatcher_with_fallback"; // Act const labels = labelsFor({ name, - factory: "getMatcher", + factory: "getPrimitiveUnionMatcher", body: " /*COMPLETE*/", tail: ", () => 0", }); @@ -668,12 +673,12 @@ test("autocomplete: a fallback makes the remaining keys optional", () => { test("autocomplete: with a fallback, handled keys stay optional", () => { // Arrange - const name = "getMatcher_with_fallback_after_key"; + const name = "getPrimitiveUnionMatcher_with_fallback_after_key"; // Act const labels = labelsFor({ name, - factory: "getMatcher", + factory: "getPrimitiveUnionMatcher", body: " a: () => 1,\n /*COMPLETE*/", 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 - const name = "getMatcherW_fresh"; + const name = "getPrimitiveUnionMatcherW_fresh"; // Act const labels = labelsFor({ name, - factory: "getMatcherW", + factory: "getPrimitiveUnionMatcherW", 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 - const name = "getMatcherW_with_fallback"; + const name = "getPrimitiveUnionMatcherW_with_fallback"; // Act const labels = labelsFor({ name, - factory: "getMatcherW", + factory: "getPrimitiveUnionMatcherW", body: " /*COMPLETE*/", 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", () => { // Arrange - const name = "getMatcher_boolean_nullish"; + const name = "getPrimitiveUnionMatcher_boolean_nullish"; // Act const labels = labelsFor({ name, - factory: "getMatcher", + factory: "getPrimitiveUnionMatcher", universe: "boolean | null | undefined", 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", () => { // Arrange - const name = "getMatcher_boolean_after_key"; + const name = "getPrimitiveUnionMatcher_boolean_after_key"; // Act const labels = labelsFor({ name, - factory: "getMatcher", + factory: "getPrimitiveUnionMatcher", universe: "boolean | null | undefined", body: " true: () => 1,\n /*COMPLETE*/", }); diff --git a/src/primitive.ts b/src/primitive-union.ts similarity index 96% rename from src/primitive.ts rename to src/primitive-union.ts index a8081e1..afd4a01 100644 --- a/src/primitive.ts +++ b/src/primitive-union.ts @@ -123,6 +123,9 @@ const dispatch = shape as never, ); -export const getMatcher = (): MatcherStrict => dispatch; -export const getMatcherW = (): MatcherWidening => - dispatch; +export const getPrimitiveUnionMatcher = < + T extends Matchable, +>(): MatcherStrict => dispatch; +export const getPrimitiveUnionMatcherW = < + T extends Matchable, +>(): MatcherWidening => dispatch; diff --git a/src/tagged-union.ts b/src/tagged-union.ts index 459024b..16f6ab4 100644 --- a/src/tagged-union.ts +++ b/src/tagged-union.ts @@ -36,7 +36,7 @@ type Handlers = { // The members `Handled` covers. Mapping over `Tags` keeps every index within // `MapTaggedUnion`'s keys, and the conditional drops a stray key outside `T` so // it cannot widen the remainder. The remainder is `Exclude`, mirroring -// the primitive matcher's `Exclude>`. +// the primitive-union matcher's `Exclude>`. type HandledMembers = { [V in Tags]: V extends keyof Handled ? MapTaggedUnion[V] @@ -52,7 +52,7 @@ type Fallback = UnaryFn< // A fallback is redundant once the handler map covers every tag of `T`. Folded // into `Handled`'s own (self-referential) constraint so it is checked *after* -// inference; see the primitive matcher for why a conditional in the fallback's +// inference; see the primitive-union matcher for why a conditional in the fallback's // parameter is evaluated too early. type MustBePartial = Tags extends keyof Handled ? RedundantFallback : unknown;