✨ 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

+26
View File
@@ -8,6 +8,32 @@ import type { ValueOf } from "type-fest";
// A handler: one universe member in, one return value out.
export type UnaryFn<T, R> = (shape: T) => R;
// `boolean`, `null` and `undefined` cannot be property keys, so a mapped type
// over a universe that includes one keys each such member by its
// stringification. `PatternParam` inverts that projection, so a handler callback
// still receives the *real* member (`true`, not `"true"`). Both matchers use the
// projection: the primitive-union matcher over its universe, the tagged-union
// matcher over a discriminant property's values. See README § Caveats for the
// limits.
export type PatternKey<T> = T extends boolean
? T extends true
? "true"
: "false"
: T extends null
? "null"
: T extends undefined
? "undefined"
: T;
export type PatternParam<K> = K extends "true"
? true
: K extends "false"
? false
: K extends "null"
? null
: K extends "undefined"
? undefined
: K;
// `Extract` drops optional handlers (`undefined`) so `PatternReturns` also works
// when `P`'s constraint has optional keys.
export type PatternReturns<P> = ReturnType<
+2 -23
View File
@@ -2,6 +2,8 @@ import type { Exact } from "type-fest";
import type {
HandlerMap,
PatternKey,
PatternParam,
PatternReturns,
RedundantFallback,
UnaryFn,
@@ -11,29 +13,6 @@ import type {
// the pair `true | false`; see README § Caveats for the unsupported members.
type Matchable = string | number | boolean | null | undefined;
// `boolean`, `null` and `undefined` cannot be property keys, so a mapped type
// over the universe keys each non-key member by its stringification. `Param`
// inverts that projection, so a handler callback still receives the *real*
// member (`true`, not `"true"`) — see README § Caveats for the limits.
type PatternKey<T> = T extends boolean
? T extends true
? "true"
: "false"
: T extends null
? "null"
: T extends undefined
? "undefined"
: T;
type PatternParam<K> = K extends "true"
? true
: K extends "false"
? false
: K extends "null"
? null
: K extends "undefined"
? undefined
: K;
type Handlers<T extends Matchable, R> = {
[K in PatternKey<T>]: UnaryFn<PatternParam<K>, R>;
};
+173 -2
View File
@@ -100,6 +100,130 @@ test("getTaggedUnionMatcher: dispatches on numeric tags", () => {
assert.equal(pick({ kind: 2, b: 20 }), 20);
});
test("getTaggedUnionMatcher: dispatches on boolean tags", () => {
// Arrange — a boolean discriminant is not a property key, so the handler
// map is keyed `true`/`false` and the callback still receives the boolean.
interface Ok {
readonly status: true;
readonly value: number;
}
interface Err {
readonly status: false;
readonly message: string;
}
type Result = Ok | Err;
const factory = getTaggedUnionMatcher<Result>()("status");
// Act
const pick = factory({
true: (s) => {
expectTypeOf(s).toEqualTypeOf<Ok>();
assert.equal(s.status, true);
return s.value;
},
false: (s) => {
expectTypeOf(s).toEqualTypeOf<Err>();
assert.equal(s.status, false);
return s.message.length;
},
});
// Assert
expectTypeOf(pick).toEqualTypeOf<(shape: Result) => number>();
assert.equal(pick({ status: true, value: 3 }), 3);
assert.equal(pick({ status: false, message: "hi" }), 2);
});
test("getTaggedUnionMatcher: dispatches on null and undefined tags", () => {
// Arrange
type Maybe =
| { readonly kind: null; readonly text: string }
| { readonly kind: undefined; readonly code: number };
const factory = getTaggedUnionMatcher<Maybe>()("kind");
// Act
const pick = factory({
null: (s) => {
expectTypeOf(s).toEqualTypeOf<{
readonly kind: null;
readonly text: string;
}>();
assert.equal(s.kind, null);
return s.text.length;
},
undefined: (s) => {
expectTypeOf(s).toEqualTypeOf<{
readonly kind: undefined;
readonly code: number;
}>();
assert.equal(s.kind, undefined);
return s.code;
},
});
// Assert
expectTypeOf(pick).toEqualTypeOf<(shape: Maybe) => number>();
assert.equal(pick({ kind: null, text: "hi" }), 2);
assert.equal(pick({ kind: undefined, code: 7 }), 7);
});
test("getTaggedUnionMatcher: a fallback receives unhandled boolean and nullish members", () => {
// Arrange
type Mixed =
| { readonly kind: "a"; readonly a: number }
| { readonly kind: true; readonly b: number }
| { readonly kind: false; readonly c: number }
| { readonly kind: null; readonly d: number };
const factory = getTaggedUnionMatcher<Mixed>()("kind");
// Act
const pick = factory({ a: () => 1 as const }, (s) => {
// The fallback sees the real members, not the projected keys.
expectTypeOf(s).toEqualTypeOf<
| { readonly kind: true; readonly b: number }
| { readonly kind: false; readonly c: number }
| { readonly kind: null; readonly d: number }
>();
return 2 as const;
});
// Assert
expectTypeOf(pick).toEqualTypeOf<(shape: Mixed) => 1 | 2>();
assert.equal(pick({ kind: "a", a: 1 }), 1);
assert.equal(pick({ kind: true, b: 1 }), 2);
assert.equal(pick({ kind: false, c: 1 }), 2);
assert.equal(pick({ kind: null, d: 1 }), 2);
});
test("getTaggedUnionMatcherW: boolean and nullish tags widen to the union", () => {
// Arrange
type Maybe =
| { readonly kind: true; readonly a: number }
| { readonly kind: false; readonly b: number }
| { readonly kind: null; readonly c: number };
const factory = getTaggedUnionMatcherW<Maybe>()("kind");
// Act
const matcher = factory({
true: (s) => {
expectTypeOf(s).toEqualTypeOf<{
readonly kind: true;
readonly a: number;
}>();
assert.equal(s.kind, true);
return "yes" as const;
},
false: () => 0 as const,
null: () => null,
});
// Assert
expectTypeOf(matcher).toEqualTypeOf<(shape: Maybe) => "yes" | 0 | null>();
assert.equal(matcher({ kind: true, a: 1 }), "yes");
assert.equal(matcher({ kind: false, b: 1 }), 0);
assert.equal(matcher({ kind: null, c: 1 }), null);
});
// ============================================================================
// API: getTaggedUnionMatcher — ❌ Exhaustive (fallback) / ✔️ ReturnsStrict
// ============================================================================
@@ -260,6 +384,26 @@ test("getTaggedUnionMatcher factory rejects a non-discriminant key", () => {
getTaggedUnionMatcher<Mixed>()("kind");
});
test("getTaggedUnionMatcher factory rejects non-exhaustive boolean and nullish maps", () => {
// Arrange
type Result =
| { readonly status: true; readonly a: number }
| { readonly status: false; readonly b: number };
type Maybe =
| { readonly status: null; readonly c: number }
| { readonly status: undefined; readonly d: number };
const resultFactory = getTaggedUnionMatcher<Result>()("status");
const maybeFactory = getTaggedUnionMatcher<Maybe>()("status");
// Act / Assert — the calls below must not compile
// @ts-expect-error only `true` is handled; `false` is not
resultFactory({ true: () => 1 });
// @ts-expect-error only `null` is handled; `undefined` is not
maybeFactory({ null: () => 1 });
// @ts-expect-error a fallback is redundant once both tags are handled
resultFactory({ true: () => 1, false: () => 2 }, () => 0);
});
test("getTaggedUnionMatcherW factory rejects patterns outside its contract", () => {
// Arrange
const factory = getTaggedUnionMatcherW<Shape>()("kind");
@@ -321,6 +465,8 @@ interface LabelsProbe {
readonly factory: "getTaggedUnionMatcher" | "getTaggedUnionMatcherW";
readonly body: string;
readonly tail?: string;
readonly typeName?: string;
readonly typeSource?: string;
}
const labelsFor = ({
@@ -328,14 +474,16 @@ const labelsFor = ({
factory,
body,
tail = "",
typeName = "Shape",
typeSource = SHAPE_SOURCE,
}: LabelsProbe): Promise<readonly string[]> => {
const session = new LspSession(REPO_ROOT);
const target: CompletionTarget = {
file: `src/__autocomplete_${name}.ts`,
source: [
`import { ${factory} } from "./index.ts";`,
SHAPE_SOURCE,
`const m = ${factory}<Shape>()("kind")({`,
typeSource,
`const m = ${factory}<${typeName}>()("kind")({`,
body,
`}${tail});`,
"",
@@ -399,6 +547,29 @@ test("autocomplete: a fallback makes the remaining tags optional", () => {
});
});
// `true`/`false`/`null`/`undefined` tags are not property keys, so the popup
// must offer them by name through the `PatternKey` projection.
const STATUS_SOURCE = `type Status = { kind: true; a: number } | { kind: false; b: number } | { kind: null; c: number } | { kind: undefined; d: number };`;
test("autocomplete: boolean and nullish tags are offered by name", () => {
// Arrange
const name = "tagged_union_boolean_nullish";
// Act
const labels = labelsFor({
name,
factory: "getTaggedUnionMatcher",
typeName: "Status",
typeSource: STATUS_SOURCE,
body: " /*COMPLETE*/",
});
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["false", "null", "true", "undefined"]);
});
});
// `Mixed` has two common keys, but `id`'s value is not a tag, so only `kind`
// may serve as the discriminant.
const MIXED_SOURCE = `type Mixed = { id: Date; kind: "a"; a: number } | { id: Date; kind: "b"; b: number };`;
+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 (