✨ 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

+22 -15
View File
@@ -2,15 +2,19 @@ import type { Exact, UnknownRecord } from "type-fest";
import type {
HandlerMap,
PatternKey,
PatternParam,
PatternReturns,
RedundantFallback,
UnaryFn,
} from "./matcher-shared.ts";
// A tagged union is discriminated by one property whose values are the tags.
// Only `string` and `number` tags can key a handler map: `symbol` has no
// literal syntax to write a handler under, and `bigint` is not a property key.
type Tag = string | number;
// `string` and `number` tags key a handler map directly; `boolean`, `null` and
// `undefined` are admitted too but are not property keys, so they go through the
// `PatternKey` projection. `symbol` has no literal syntax to write a handler
// under, and `bigint` is not a property key.
type Tag = string | number | boolean | null | undefined;
// The discriminant values of `T` under `K`. `Extract` keeps the finite literal
// tags and leaves a widened `string`/`number` as itself, so an open universe
@@ -25,23 +29,26 @@ type Discriminated<T extends object> = {
// The member(s) of `T` tagged `V`. `Extract` distributes over the union, so a
// duplicated tag maps to a union of members rather than silently dropping one.
// Keyed by `PatternKey`, so `boolean`/`null`/`undefined` tags can key a mapped
// type; `PatternParam` inverts the projection to recover the member.
type MapTaggedUnion<T extends object, K extends keyof T> = {
[V in Tags<T, K>]: Extract<T, Record<K, V>>;
[P in PatternKey<Tags<T, K>>]: Extract<T, Record<K, PatternParam<P>>>;
};
type Handlers<T extends object, K extends keyof T, R> = {
[V in Tags<T, K>]: UnaryFn<MapTaggedUnion<T, K>[V], R>;
[P in PatternKey<Tags<T, K>>]: UnaryFn<MapTaggedUnion<T, K>[P], R>;
};
// 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<T, …>`, mirroring
// the primitive-union matcher's `Exclude<T, PatternParam<keyof Handled>>`.
// The members `Handled` covers. Mapping over the projected keys 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<T, …>`, mirroring the primitive-union matcher's
// `Exclude<T, PatternParam<keyof Handled>>`.
type HandledMembers<T extends object, K extends keyof T, Handled> = {
[V in Tags<T, K>]: V extends keyof Handled
? MapTaggedUnion<T, K>[V]
[P in PatternKey<Tags<T, K>>]: P extends keyof Handled
? MapTaggedUnion<T, K>[P]
: never;
}[Tags<T, K>];
}[PatternKey<Tags<T, K>>];
// The fallback is a *second argument*, not a property of the handler map, so
// its parameter can be the remainder the map left open. See development/library.md.
@@ -55,7 +62,7 @@ type Fallback<T extends object, K extends keyof T, Handled, R> = UnaryFn<
// inference; see the primitive-union matcher for why a conditional in the fallback's
// parameter is evaluated too early.
type MustBePartial<T extends object, K extends keyof T, Handled> =
Tags<T, K> extends keyof Handled ? RedundantFallback : unknown;
PatternKey<Tags<T, K>> extends keyof Handled ? RedundantFallback : unknown;
// TypeScript does not apply the excess-property check to a generic constraint,
// so `Exact` restores it for the generic forms.
@@ -97,8 +104,8 @@ const dispatch =
(handlers: HandlerMap, fallback?: UnaryFn<never, unknown>) =>
(shape: object): unknown => {
// `object` carries no index signature, so the read needs the assertion;
// the factory admits only keys whose values are `string | number` tags,
// so the result is narrowed to the map's key space.
// the factory admits only keys whose values are tags, and the map keys
// them by `PatternKey`, so the result is narrowed to the map's key space.
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
const tag = (shape as UnknownRecord)[k] as string | number;
return (