✨ Allow boolean and nullish tags in tagged unions

A `boolean` / `null` / `undefined` discriminant cannot key a handler
map, so the tagged-union matcher now routes it through the
`PatternKey` / `PatternParam` projection the primitive-union matcher
already used. Both matchers share the projection from
`matcher-shared.ts`; `MapTaggedUnion`, `Handlers`, `HandledMembers`
and `MustBePartial` key off the projected form and invert it to
recover the real member.

Covers exhaustive and fallback dispatch, the redundant-fallback
guard, and the LSP popup offering the tags by name.
This commit is contained in:
tmu committed 2026-09-22 10:22:59 +00:00
1 parent b81a8149d9
commit e96ca12149
5 files changed
+243 -52

No files matched your search

+20 -12
View File
@@ -97,22 +97,25 @@ Each factory is two overloads whose order is load-bearing:
#### Decision (2026-09)
`src/matcher-shared.ts` holds the four universe-agnostic pieces both matchers
use: `UnaryFn`, `PatternReturns`, `RedundantFallback`, `HandlerMap`.
`src/matcher-shared.ts` holds the six universe-agnostic pieces both matchers
use: `UnaryFn`, `PatternReturns`, `RedundantFallback`, `HandlerMap`, and the
`PatternKey` / `PatternParam` key projection.
#### Why
- `RedundantFallback`'s property name is the diagnostic, so one definition
keeps the two matchers' message from drifting; the other three appear verbatim
in both public signatures.
keeps the two matchers' message from drifting; the other pieces appear
verbatim in both public signatures or are the same projection over each
matcher's universe.
#### Rejected
- **A generic `Matcher<Universe>` over the interface pair, `Handlers`,
`Fallback` and `MustBePartial`.** Each is built from its own universe
(`PatternKey`/`PatternParam` vs `Tags`/`MapTaggedUnion`); abstracting over the
(`Tags`/`MapTaggedUnion` vs the primitive values); abstracting over the
F-bounded `Handled` constraint that makes the remainder work risks the
contextual typing it exists to preserve.
contextual typing it exists to preserve. The `PatternKey` / `PatternParam`
projection is the one piece both universes genuinely share.
## Tagged-union matcher
@@ -152,15 +155,18 @@ The key is a separate call because `K` is inferred from its literal argument and
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.
- **A `boolean` / `null` / `undefined` tag goes through the shared
`PatternKey` / `PatternParam` projection.** `Discriminated` admits those tags
(they are in `Tag`), but they cannot key a mapped type, so the handler map is
keyed by the stringified form (`true` → `"true"`) and `PatternParam` inverts
it to recover the member. This is the same projection the primitive-union
matcher uses over its universe, which is why it lives in `matcher-shared.ts`.
#### Known issue
- 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-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.
collapse to a union under one handler. The same holds for a tag colliding with
its stringification (`true | "true"`) — see [README § Caveats](../README.md#caveats).
## Primitive universe
@@ -172,7 +178,9 @@ with `boolean` admitted as `true | false`.
`boolean`/`null`/`undefined` are not property keys, so handler-map keys are a
projection (`PatternKey`: each member stringified) and `PatternParam` inverts
it, so callbacks receive the real member (`true`, not `"true"`). The popup
offers `true`, `false`, `null`, `undefined` by name (verified over LSP).
offers `true`, `false`, `null`, `undefined` by name (verified over LSP). The
same projection is shared with the tagged-union matcher; see
§ Tagged-union matcher.
#### Why