✨ 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:
1 parent
b81a8149d9
commit
e96ca12149
5 files changed
+243
-52
No files matched your search
+173
-2
@@ -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 };`;
|
||||
|
||||
Reference in new issue
Block a user