Files
tiny-pattern-ts/src/tagged-union.test.ts
T
2026-09-23 20:30:35 +00:00

1607 lines
51 KiB
TypeScript

import { strict as assert } from "node:assert";
import path from "node:path";
import { test } from "node:test";
import { expectTypeOf } from "expect-type";
import {
type CompletionTarget,
LspSession,
} from "#test-utils/lsp-completion.ts";
import {
getTaggedUnionMatcher,
getTaggedUnionMatcherW,
} from "./tagged-union.ts";
interface Circle {
readonly kind: "circle";
readonly radius: number;
}
interface Square {
readonly kind: "square";
readonly side: number;
}
interface Triangle {
readonly kind: "triangle";
readonly base: number;
readonly height: number;
}
type Shape = Circle | Square | Triangle;
// ============================================================================
// API: getTaggedUnionMatcher — ✔️ Exhaustive / ✔️ ReturnsStrict
// ============================================================================
test("getTaggedUnionMatcher: exhaustive pattern infers one common return type", () => {
// Arrange
const factory = getTaggedUnionMatcher<Shape>()("kind");
// Act
const area = factory({
circle: (s) => {
// Each handler receives the member its tag selects, not the union.
expectTypeOf(s).toEqualTypeOf<Circle>();
assert.equal(s.kind, "circle");
return Math.PI * s.radius ** 2;
},
square: (s) => {
expectTypeOf(s).toEqualTypeOf<Square>();
assert.equal(s.kind, "square");
return s.side ** 2;
},
triangle: (s) => {
expectTypeOf(s).toEqualTypeOf<Triangle>();
assert.equal(s.kind, "triangle");
return (s.base * s.height) / 2;
},
});
// Assert
// ✔️ ReturnsStrict: R is the best common return type, not a widening union.
expectTypeOf(area).toEqualTypeOf<(shape: Shape) => number>();
assert.equal(area({ kind: "square", side: 2 }), 4);
assert.equal(area({ kind: "triangle", base: 2, height: 3 }), 3);
});
test("getTaggedUnionMatcher: dispatches on numeric tags", () => {
// Arrange
type Version =
| { readonly kind: 1; readonly a: number }
| {
readonly kind: 2;
readonly b: number;
};
const factory = getTaggedUnionMatcher<Version>()("kind");
// Act
const pick = factory({
1: (v) => {
expectTypeOf(v).toEqualTypeOf<{
readonly kind: 1;
readonly a: number;
}>();
assert.equal(v.kind, 1);
return v.a;
},
2: (v) => {
expectTypeOf(v).toEqualTypeOf<{
readonly kind: 2;
readonly b: number;
}>();
assert.equal(v.kind, 2);
return v.b;
},
});
// Assert
expectTypeOf(pick).toEqualTypeOf<(shape: Version) => number>();
assert.equal(pick({ kind: 1, a: 10 }), 10);
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 }
>();
// `true`/`false`/`null` are keyed as `"true"`/`"false"`/`"null"`,
// but the fallback still receives the boolean/null, not the string.
assert.ok(s.kind === true || s.kind === false || s.kind === null);
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: (s) => {
expectTypeOf(s).toEqualTypeOf<{
readonly kind: false;
readonly b: number;
}>();
assert.equal(s.kind, false);
return 0 as const;
},
null: (s) => {
expectTypeOf(s).toEqualTypeOf<{
readonly kind: null;
readonly c: number;
}>();
assert.equal(s.kind, null);
return 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
// ============================================================================
test("getTaggedUnionMatcher: a fallback receives the unhandled members", () => {
// Arrange
const factory = getTaggedUnionMatcher<Shape>()("kind");
// Act
const area = factory(
{
circle: (s) => {
expectTypeOf(s).toEqualTypeOf<Circle>();
assert.equal(s.kind, "circle");
return 1 as const;
},
},
(s) => {
// The fallback sees only the members `circle` did not handle.
expectTypeOf(s).toEqualTypeOf<Square | Triangle>();
assert.ok(s.kind === "square" || s.kind === "triangle");
return 2 as const;
},
);
// Assert
expectTypeOf(area).toEqualTypeOf<(shape: Shape) => 1 | 2>();
assert.equal(area({ kind: "circle", radius: 1 }), 1);
assert.equal(area({ kind: "square", side: 1 }), 2);
assert.equal(area({ kind: "triangle", base: 1, height: 1 }), 2);
});
test("getTaggedUnionMatcher: an open discriminant is rejected with a fallback too", () => {
// Arrange
interface Message {
readonly kind: string;
readonly text: string;
}
// Act / Assert — the call below must not compile; a broad tag is unsupported
// whether or not a fallback is supplied.
// @ts-expect-error `string` tags are not a finite union of literals
getTaggedUnionMatcher<Message>()("kind")({ info: () => 1 }, () => 2);
});
// ============================================================================
// API: getTaggedUnionMatcherW — ✔️ Exhaustive / ❌ ReturnsStrict
// ============================================================================
test("getTaggedUnionMatcherW: exhaustive pattern widens to the union of returns", () => {
// Arrange
const factory = getTaggedUnionMatcherW<Shape>()("kind");
// Act
const describe = factory({
circle: (s) => {
expectTypeOf(s).toEqualTypeOf<Circle>();
assert.equal(s.kind, "circle");
return "round" as const;
},
square: (s) => {
expectTypeOf(s).toEqualTypeOf<Square>();
assert.equal(s.kind, "square");
return 4 as const;
},
triangle: (s) => {
expectTypeOf(s).toEqualTypeOf<Triangle>();
assert.equal(s.kind, "triangle");
return true as const;
},
});
// Assert
// ❌ ReturnsStrict: mixed handler returns widen to their union.
expectTypeOf(describe).toEqualTypeOf<
(shape: Shape) => "round" | 4 | true
>();
assert.equal(describe({ kind: "circle", radius: 1 }), "round");
assert.equal(describe({ kind: "square", side: 1 }), 4);
assert.equal(describe({ kind: "triangle", base: 1, height: 1 }), true);
});
// ============================================================================
// API: getTaggedUnionMatcherW — ❌ Exhaustive (fallback) / ❌ ReturnsStrict
// ============================================================================
test("getTaggedUnionMatcherW: a fallback widens gaps into the union", () => {
// Arrange
const factory = getTaggedUnionMatcherW<Shape>()("kind");
// Act
const matcher = factory({ circle: () => 1 as const }, (s) => {
expectTypeOf(s).toEqualTypeOf<Square | Triangle>();
// Returning the member verbatim lets the `Assert` block check the
// exact value dispatch passed.
return s;
});
// Assert
expectTypeOf(matcher).toEqualTypeOf<
(shape: Shape) => 1 | Square | Triangle
>();
assert.equal(matcher({ kind: "circle", radius: 1 }), 1);
assert.deepEqual(matcher({ kind: "square", side: 1 }), {
kind: "square",
side: 1,
});
assert.deepEqual(matcher({ kind: "triangle", base: 1, height: 1 }), {
kind: "triangle",
base: 1,
height: 1,
});
});
// ============================================================================
// Factory contracts — calls that must not compile
// ============================================================================
test("getTaggedUnionMatcher factory rejects patterns outside its contract", () => {
// Arrange
const factory = getTaggedUnionMatcher<Shape>()("kind");
// Act / Assert — the calls below must not compile
factory({
circle: () => 1,
square: () => 2,
triangle: () => 3,
// @ts-expect-error `hexagon` is not a tag of Shape
hexagon: () => 4,
});
// @ts-expect-error a gap without a fallback is not exhaustive
factory({ circle: () => 1 });
// @ts-expect-error a `string` fallback does not fit the `number` handlers
factory({ circle: () => 1 }, () => "x");
// @ts-expect-error a fallback is redundant once the map covers the union
factory({ circle: () => 1, square: () => 2, triangle: () => 3 }, () => 0);
});
test("getTaggedUnionMatcher factory rejects a non-discriminant key", () => {
// Arrange
type Mixed =
| { readonly id: Date; readonly kind: "a" }
| { readonly id: Date; readonly kind: "b" };
// Act / Assert — the calls below must not compile
// @ts-expect-error `id` is a common key but its value is not a tag
getTaggedUnionMatcher<Mixed>()("id");
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");
// Act / Assert — the calls below must not compile
factory({
circle: () => 1 as const,
square: () => 2 as const,
triangle: () => 3 as const,
// @ts-expect-error `hexagon` is not a tag of Shape
hexagon: () => 4 as const,
});
// @ts-expect-error a gap without a fallback is not exhaustive
factory({ circle: () => 1 as const });
factory(
// @ts-expect-error a fallback is redundant once the map covers the union
{
circle: () => 1 as const,
square: () => 2 as const,
triangle: () => 3 as const,
},
() => 0,
);
});
test("getTaggedUnionMatcher: a standalone stringified tag is typed by the member, not its key", () => {
// `kind: "true"` is a string tag whose `PatternKey` is `"true"`; inverting
// the key must recover the member, not the boolean tag.
// Arrange
interface StringTag {
readonly kind: "true";
readonly a: number;
}
const factory = getTaggedUnionMatcher<StringTag>()("kind");
// Act
const matcher = factory({
true: (s) => {
expectTypeOf(s).toEqualTypeOf<StringTag>();
assert.equal(s.kind, "true");
return 1;
},
});
// Assert
assert.equal(matcher({ kind: "true", a: 1 }), 1);
});
test("getTaggedUnionMatcher: a broad tag is rejected", () => {
// Arrange
interface Message {
readonly kind: string;
}
interface Count {
readonly kind: number;
}
// Act / Assert — the calls below must not compile
// @ts-expect-error `string` tags are not a finite union of literals
getTaggedUnionMatcher<Message>()("kind")({ a: () => 1 });
// @ts-expect-error `number` tags are not a finite union of literals
getTaggedUnionMatcher<Count>()("kind")({ 1: () => 1 });
// @ts-expect-error the widening factory rejects broad tags too
getTaggedUnionMatcherW<Message>()("kind")({ a: () => 1 });
});
test("getTaggedUnionMatcher: a value/stringification tag collision is rejected", () => {
// Arrange
type Colliding =
| { readonly kind: "true"; readonly a: number }
| { readonly kind: true; readonly b: number };
type NumericColliding =
| { readonly kind: "1"; readonly a: number }
| { readonly kind: 1; readonly b: number };
// Act / Assert — the calls below must not compile
// @ts-expect-error `true` collides with the string tag `"true"`
getTaggedUnionMatcher<Colliding>()("kind")({ true: () => 1 });
// @ts-expect-error the number tag `1` collides with the string tag `"1"`
getTaggedUnionMatcher<NumericColliding>()("kind")({ "1": () => 1 });
});
test("getTaggedUnionMatcher: a tag collision is rejected beside legal members", () => {
// Act / Assert — the calls below must not compile. Each map is otherwise
// complete, so only the collision can make it invalid.
type TrueColliding =
| { readonly kind: "true"; readonly a: number }
| { readonly kind: true; readonly b: number }
| { readonly kind: "a"; readonly c: number };
// @ts-expect-error `true` collides with the string tag `"true"`
getTaggedUnionMatcher<TrueColliding>()("kind")({
true: () => 1,
a: () => 2,
});
type FalseColliding =
| { readonly kind: "false"; readonly a: number }
| { readonly kind: false; readonly b: number }
| { readonly kind: "a"; readonly c: number };
// @ts-expect-error `false` collides with the string tag `"false"`
getTaggedUnionMatcher<FalseColliding>()("kind")({
false: () => 1,
a: () => 2,
});
type NullColliding =
| { readonly kind: "null"; readonly a: number }
| { readonly kind: null; readonly b: number }
| { readonly kind: "a"; readonly c: number };
// @ts-expect-error `null` collides with the string tag `"null"`
getTaggedUnionMatcher<NullColliding>()("kind")({
null: () => 1,
a: () => 2,
});
type UndefinedColliding =
| { readonly kind: "undefined"; readonly a: number }
| { readonly kind: undefined; readonly b: number }
| { readonly kind: "a"; readonly c: number };
// @ts-expect-error `undefined` collides with the string tag `"undefined"`
getTaggedUnionMatcher<UndefinedColliding>()("kind")({
undefined: () => 1,
a: () => 2,
});
type NumericColliding =
| { readonly kind: "1"; readonly a: number }
| { readonly kind: 1; readonly b: number }
| { readonly kind: "a"; readonly c: number };
// @ts-expect-error the number tag `1` collides with the string tag `"1"`
getTaggedUnionMatcher<NumericColliding>()("kind")({
"1": () => 1,
a: () => 2,
});
});
test('getTaggedUnionMatcher: a `"true"` tag and a `false` tag are legal together', () => {
// Arrange
interface TrueString {
readonly kind: "true";
readonly a: number;
}
interface FalseBare {
readonly kind: false;
readonly b: number;
}
interface Extra {
readonly kind: "a";
readonly c: number;
}
type Legal = TrueString | FalseBare | Extra;
// Act
const matcher = getTaggedUnionMatcher<Legal>()("kind")({
true: (s) => {
expectTypeOf(s).toEqualTypeOf<TrueString>();
assert.equal(s.kind, "true");
return 1;
},
false: (s) => {
expectTypeOf(s).toEqualTypeOf<FalseBare>();
assert.equal(s.kind, false);
return 2;
},
a: (s) => {
expectTypeOf(s).toEqualTypeOf<Extra>();
assert.equal(s.kind, "a");
return 3;
},
});
// Assert
assert.equal(matcher({ kind: "true", a: 1 }), 1);
assert.equal(matcher({ kind: false, b: 2 }), 2);
assert.equal(matcher({ kind: "a", c: 3 }), 3);
});
test('getTaggedUnionMatcher: a `"false"` tag and a `true` tag are legal together', () => {
// Arrange
interface FalseString {
readonly kind: "false";
readonly a: number;
}
interface TrueBare {
readonly kind: true;
readonly b: number;
}
interface Extra {
readonly kind: "a";
readonly c: number;
}
type Legal = FalseString | TrueBare | Extra;
// Act
const matcher = getTaggedUnionMatcher<Legal>()("kind")({
false: (s) => {
expectTypeOf(s).toEqualTypeOf<FalseString>();
assert.equal(s.kind, "false");
return 1;
},
true: (s) => {
expectTypeOf(s).toEqualTypeOf<TrueBare>();
assert.equal(s.kind, true);
return 2;
},
a: (s) => {
expectTypeOf(s).toEqualTypeOf<Extra>();
assert.equal(s.kind, "a");
return 3;
},
});
// Assert
assert.equal(matcher({ kind: "false", a: 1 }), 1);
assert.equal(matcher({ kind: true, b: 2 }), 2);
assert.equal(matcher({ kind: "a", c: 3 }), 3);
});
test('getTaggedUnionMatcher: a `"null"` tag and an `undefined` tag are legal together', () => {
// Arrange
interface NullString {
readonly kind: "null";
readonly a: number;
}
interface UndefinedBare {
readonly kind: undefined;
readonly b: number;
}
interface Extra {
readonly kind: "a";
readonly c: number;
}
type Legal = NullString | UndefinedBare | Extra;
// Act
const matcher = getTaggedUnionMatcher<Legal>()("kind")({
null: (s) => {
expectTypeOf(s).toEqualTypeOf<NullString>();
assert.equal(s.kind, "null");
return 1;
},
undefined: (s) => {
expectTypeOf(s).toEqualTypeOf<UndefinedBare>();
assert.equal(s.kind, undefined);
return 2;
},
a: (s) => {
expectTypeOf(s).toEqualTypeOf<Extra>();
assert.equal(s.kind, "a");
return 3;
},
});
// Assert
assert.equal(matcher({ kind: "null", a: 1 }), 1);
assert.equal(matcher({ kind: undefined, b: 2 }), 2);
assert.equal(matcher({ kind: "a", c: 3 }), 3);
});
test('getTaggedUnionMatcher: an `"undefined"` tag and a `null` tag are legal together', () => {
// Arrange
interface UndefinedString {
readonly kind: "undefined";
readonly a: number;
}
interface NullBare {
readonly kind: null;
readonly b: number;
}
interface Extra {
readonly kind: "a";
readonly c: number;
}
type Legal = UndefinedString | NullBare | Extra;
// Act
const matcher = getTaggedUnionMatcher<Legal>()("kind")({
undefined: (s) => {
expectTypeOf(s).toEqualTypeOf<UndefinedString>();
assert.equal(s.kind, "undefined");
return 1;
},
null: (s) => {
expectTypeOf(s).toEqualTypeOf<NullBare>();
assert.equal(s.kind, null);
return 2;
},
a: (s) => {
expectTypeOf(s).toEqualTypeOf<Extra>();
assert.equal(s.kind, "a");
return 3;
},
});
// Assert
assert.equal(matcher({ kind: "undefined", a: 1 }), 1);
assert.equal(matcher({ kind: null, b: 2 }), 2);
assert.equal(matcher({ kind: "a", c: 3 }), 3);
});
test('getTaggedUnionMatcher: a `"1"` tag beside other tags is legal', () => {
// Arrange
interface OneString {
readonly kind: "1";
readonly a: number;
}
interface TwoBare {
readonly kind: 2;
readonly b: number;
}
interface ThreeBare {
readonly kind: 3;
readonly c: number;
}
interface FourString {
readonly kind: "4";
readonly d: number;
}
type Legal = OneString | TwoBare | ThreeBare | FourString;
// Act
const matcher = getTaggedUnionMatcher<Legal>()("kind")({
"1": (s) => {
expectTypeOf(s).toEqualTypeOf<OneString>();
assert.equal(s.kind, "1");
return 1;
},
2: (s) => {
expectTypeOf(s).toEqualTypeOf<TwoBare>();
assert.equal(s.kind, 2);
return 2;
},
3: (s) => {
expectTypeOf(s).toEqualTypeOf<ThreeBare>();
assert.equal(s.kind, 3);
return 3;
},
"4": (s) => {
expectTypeOf(s).toEqualTypeOf<FourString>();
assert.equal(s.kind, "4");
return 4;
},
});
// Assert
assert.equal(matcher({ kind: "1", a: 1 }), 1);
assert.equal(matcher({ kind: 2, b: 2 }), 2);
assert.equal(matcher({ kind: 3, c: 3 }), 3);
assert.equal(matcher({ kind: "4", d: 4 }), 4);
});
// The maximum legal tag universe needs one handler per member; the statement
// count is inherent to the case.
// oxlint-disable-next-line max-statements
test("getTaggedUnionMatcher: the maximum legal tag universe is supported", () => {
// Arrange — every tag kind, one representation per value, plus extra tags.
interface M1 {
readonly kind: "true";
readonly a: 1;
}
interface M2 {
readonly kind: false;
readonly b: 2;
}
interface M3 {
readonly kind: null;
readonly c: 3;
}
interface M4 {
readonly kind: undefined;
readonly d: 4;
}
interface M5 {
readonly kind: "1";
readonly e: 5;
}
interface M6 {
readonly kind: 2;
readonly f: 6;
}
interface M7 {
readonly kind: 3;
readonly g: 7;
}
interface M8 {
readonly kind: "a";
readonly h: 8;
}
interface M9 {
readonly kind: "b";
readonly i: 9;
}
type Legal = M1 | M2 | M3 | M4 | M5 | M6 | M7 | M8 | M9;
// Act
const matcher = getTaggedUnionMatcher<Legal>()("kind")({
true: (s) => {
expectTypeOf(s).toEqualTypeOf<M1>();
assert.equal(s.kind, "true");
return 1;
},
false: (s) => {
expectTypeOf(s).toEqualTypeOf<M2>();
assert.equal(s.kind, false);
return 2;
},
null: (s) => {
expectTypeOf(s).toEqualTypeOf<M3>();
assert.equal(s.kind, null);
return 3;
},
undefined: (s) => {
expectTypeOf(s).toEqualTypeOf<M4>();
assert.equal(s.kind, undefined);
return 4;
},
"1": (s) => {
expectTypeOf(s).toEqualTypeOf<M5>();
assert.equal(s.kind, "1");
return 5;
},
2: (s) => {
expectTypeOf(s).toEqualTypeOf<M6>();
assert.equal(s.kind, 2);
return 6;
},
3: (s) => {
expectTypeOf(s).toEqualTypeOf<M7>();
assert.equal(s.kind, 3);
return 7;
},
a: (s) => {
expectTypeOf(s).toEqualTypeOf<M8>();
assert.equal(s.kind, "a");
return 8;
},
b: (s) => {
expectTypeOf(s).toEqualTypeOf<M9>();
assert.equal(s.kind, "b");
return 9;
},
});
// Assert
expectTypeOf(matcher).toEqualTypeOf<(shape: Legal) => number>();
assert.equal(matcher({ kind: "true", a: 1 }), 1);
assert.equal(matcher({ kind: false, b: 2 }), 2);
assert.equal(matcher({ kind: null, c: 3 }), 3);
assert.equal(matcher({ kind: undefined, d: 4 }), 4);
assert.equal(matcher({ kind: "1", e: 5 }), 5);
assert.equal(matcher({ kind: 2, f: 6 }), 6);
assert.equal(matcher({ kind: 3, g: 7 }), 7);
assert.equal(matcher({ kind: "a", h: 8 }), 8);
assert.equal(matcher({ kind: "b", i: 9 }), 9);
});
test("getTaggedUnionMatcher: the maximum illegal tag universe is rejected", () => {
// Arrange — every tag paired with its stringification.
type Illegal =
| { readonly kind: "true"; readonly a: 1 }
| { readonly kind: true; readonly a: 1 }
| { readonly kind: "false"; readonly b: 2 }
| { readonly kind: false; readonly b: 2 }
| { readonly kind: "null"; readonly c: 3 }
| { readonly kind: null; readonly c: 3 }
| { readonly kind: "undefined"; readonly d: 4 }
| { readonly kind: undefined; readonly d: 4 }
| { readonly kind: "1"; readonly e: 5 }
| { readonly kind: 1; readonly e: 5 };
// Act / Assert — the call below must not compile
// @ts-expect-error every tag collides with its stringification
getTaggedUnionMatcher<Illegal>()("kind")({
true: () => 1,
false: () => 2,
null: () => 3,
undefined: () => 4,
"1": () => 5,
});
});
// ============================================================================
// Property unions
// ============================================================================
test("property union: narrows each handler", () => {
// Arrange — one shape, not a union of variants; `color` is a union.
interface Paint {
readonly color: "red" | "green" | "blue";
readonly value: number;
}
const factory = getTaggedUnionMatcher<Paint>()("color");
// Act
const describe = factory({
red: (s) => {
expectTypeOf(s).toEqualTypeOf<{
readonly color: "red";
readonly value: number;
}>();
assert.equal(s.color, "red");
return s.value;
},
green: (s) => {
expectTypeOf(s).toEqualTypeOf<{
readonly color: "green";
readonly value: number;
}>();
assert.equal(s.color, "green");
return s.value;
},
blue: (s) => {
expectTypeOf(s).toEqualTypeOf<{
readonly color: "blue";
readonly value: number;
}>();
assert.equal(s.color, "blue");
return s.value;
},
});
// Assert
expectTypeOf(describe).toEqualTypeOf<(shape: Paint) => number>();
assert.equal(describe({ color: "red", value: 1 }), 1);
assert.equal(describe({ color: "green", value: 2 }), 2);
assert.equal(describe({ color: "blue", value: 3 }), 3);
});
test("property union: a fallback receives the unhandled values", () => {
// Arrange
interface Paint {
color: "red" | "green" | "blue";
value: number;
}
const factory = getTaggedUnionMatcher<Paint>()("color");
// Act
const describe = factory(
{
red: (s) => {
expectTypeOf(s).toEqualTypeOf<{
color: "red";
value: number;
}>();
assert.equal(s.color, "red");
return 1 as const;
},
},
(s) => {
expectTypeOf(s).toEqualTypeOf<{
color: "green" | "blue";
value: number;
}>();
assert.ok(s.color === "green" || s.color === "blue");
return 2 as const;
},
);
// Assert
expectTypeOf(describe).toEqualTypeOf<(shape: Paint) => 1 | 2>();
assert.equal(describe({ color: "red", value: 1 }), 1);
assert.equal(describe({ color: "green", value: 2 }), 2);
assert.equal(describe({ color: "blue", value: 3 }), 2);
});
test("property union: a numeric property narrows each handler", () => {
// Arrange
interface Version {
code: 1 | 2 | 3;
value: number;
}
const factory = getTaggedUnionMatcher<Version>()("code");
// Act
const describe = factory({
1: (s) => {
expectTypeOf(s).toEqualTypeOf<{ code: 1; value: number }>();
assert.equal(s.code, 1);
return 1;
},
2: (s) => {
expectTypeOf(s).toEqualTypeOf<{ code: 2; value: number }>();
assert.equal(s.code, 2);
return 2;
},
3: (s) => {
expectTypeOf(s).toEqualTypeOf<{ code: 3; value: number }>();
assert.equal(s.code, 3);
return 3;
},
});
// Assert
expectTypeOf(describe).toEqualTypeOf<(shape: Version) => number>();
assert.equal(describe({ code: 1, value: 10 }), 1);
assert.equal(describe({ code: 2, value: 20 }), 2);
assert.equal(describe({ code: 3, value: 30 }), 3);
});
test("property union: an optional property is required for a defined tag", () => {
// Arrange — `type?` is `"x" | undefined`; both handlers must be typed,
// and matching `"x"` proves the key is present, so `type` becomes required.
interface Maybe {
type?: "x";
value: number;
}
const factory = getTaggedUnionMatcher<Maybe>()("type");
// Act
const describe = factory({
x: (s) => {
expectTypeOf(s).toEqualTypeOf<{ type: "x"; value: number }>();
assert.equal(s.type, "x");
return 1;
},
undefined: (s) => {
// The input only allows absence (exactOptionalPropertyTypes), so
// the `undefined` tag narrows the key to never-present.
expectTypeOf(s).toEqualTypeOf<{ type?: never; value: number }>();
assert.equal(s.type, undefined);
return 2;
},
});
// Assert
expectTypeOf(describe).toEqualTypeOf<(shape: Maybe) => number>();
assert.equal(describe({ type: "x", value: 1 }), 1);
assert.equal(describe({ value: 2 }), 2);
});
test("property union: an optional fallback receives the undefined tag", () => {
// Arrange
interface Maybe {
type?: "x";
value: number;
}
const factory = getTaggedUnionMatcher<Maybe>()("type");
// Act
const describe = factory(
{
x: (s) => {
expectTypeOf(s).toEqualTypeOf<{ type: "x"; value: number }>();
assert.equal(s.type, "x");
return 1 as const;
},
},
(s) => {
expectTypeOf(s).toEqualTypeOf<{
type?: never;
value: number;
}>();
assert.equal(s.type, undefined);
return 2 as const;
},
);
// Assert
expectTypeOf(describe).toEqualTypeOf<(shape: Maybe) => 1 | 2>();
assert.equal(describe({ type: "x", value: 1 }), 1);
assert.equal(describe({ value: 2 }), 2);
});
test("property union: explicit `undefined` stays distinct from absence", () => {
// Arrange — `type?: "x" | undefined` admits an explicit `undefined`, unlike
// the bare optional above; the two representations must not be conflated.
interface Explicit {
type?: "x" | undefined;
value: number;
}
const factory = getTaggedUnionMatcher<Explicit>()("type");
// Act
const describe = factory({
x: (s) => {
expectTypeOf(s).toEqualTypeOf<{ type: "x"; value: number }>();
assert.equal(s.type, "x");
return 1;
},
undefined: (s) => {
expectTypeOf(s).toEqualTypeOf<{
type?: undefined;
value: number;
}>();
assert.equal(s.type, undefined);
return 2;
},
});
// Assert
expectTypeOf(describe).toEqualTypeOf<(shape: Explicit) => number>();
assert.equal(describe({ type: "x", value: 1 }), 1);
assert.equal(describe({ type: undefined, value: 2 }), 2);
assert.equal(describe({ value: 3 }), 2);
});
test("property union: a union-valued member is split, not dropped", () => {
// A member whose tag is itself a union sits beside a singleton-tag member.
// Selecting members with `Extract<T, Record<K, V>>` keeps only the
// singleton; the distributive narrowing must keep both sides of the
// union-valued member.
// Arrange
type Mixed = { kind: "a"; a: number } | { kind: "a" | "b"; b: number };
const factory = getTaggedUnionMatcher<Mixed>()("kind");
// Act
const describe = factory({
a: (s) => {
expectTypeOf(s).toEqualTypeOf<
{ kind: "a"; a: number } | { kind: "a"; b: number }
>();
assert.equal(s.kind, "a");
return 1;
},
b: (s) => {
expectTypeOf(s).toEqualTypeOf<{ kind: "b"; b: number }>();
assert.equal(s.kind, "b");
return 2;
},
});
// Assert
expectTypeOf(describe).toEqualTypeOf<(shape: Mixed) => number>();
assert.equal(describe({ kind: "a", a: 1 }), 1);
assert.equal(describe({ kind: "a", b: 2 }), 1);
assert.equal(describe({ kind: "b", b: 3 }), 2);
});
test("property union: getTaggedUnionMatcherW widens the returns", () => {
// Arrange
interface Paint {
color: "red" | "green" | "blue";
value: number;
}
const factory = getTaggedUnionMatcherW<Paint>()("color");
// Act
const describe = factory({
red: (s) => {
expectTypeOf(s).toEqualTypeOf<{ color: "red"; value: number }>();
assert.equal(s.color, "red");
return "r" as const;
},
green: (s) => {
expectTypeOf(s).toEqualTypeOf<{ color: "green"; value: number }>();
assert.equal(s.color, "green");
return 1 as const;
},
blue: (s) => {
expectTypeOf(s).toEqualTypeOf<{ color: "blue"; value: number }>();
assert.equal(s.color, "blue");
return true as const;
},
});
// Assert
expectTypeOf(describe).toEqualTypeOf<(shape: Paint) => "r" | 1 | true>();
assert.equal(describe({ color: "red", value: 1 }), "r");
assert.equal(describe({ color: "green", value: 2 }), 1);
assert.equal(describe({ color: "blue", value: 3 }), true);
});
test("property union: enforces its contract", () => {
// Arrange
interface Paint {
color: "red" | "green" | "blue";
value: number;
}
const factory = getTaggedUnionMatcher<Paint>()("color");
// Act / Assert — the calls below must not compile
factory({
red: () => 1,
green: () => 2,
blue: () => 3,
// @ts-expect-error `yellow` is not a value of `color`
yellow: () => 4,
});
// @ts-expect-error a gap without a fallback is not exhaustive
factory({ red: () => 1 });
// @ts-expect-error a fallback is redundant once every value is handled
factory({ red: () => 1, green: () => 2, blue: () => 3 }, () => 0);
});
test("property union: a broad value in the union is rejected", () => {
// Arrange — the property is a union, but not a finite union of literals: a
// template literal cannot be proven exhaustive.
interface Template {
kind: "a" | `x-${number}`;
a: number;
}
// Act / Assert — the calls below must not compile
// @ts-expect-error a template literal is not a finite literal
getTaggedUnionMatcher<Template>()("kind")({ a: () => 1 });
// @ts-expect-error the widening factory rejects broad values too
getTaggedUnionMatcherW<Template>()("kind")({ a: () => 1 });
});
test("property union: a value/stringification collision is rejected", () => {
// Arrange — one property holding both a value and its stringification; both
// would key the same handler.
interface BooleanColliding {
kind: "true" | true;
a: number;
}
interface NumericColliding {
kind: "1" | 1;
a: number;
}
// Act / Assert — the calls below must not compile
// @ts-expect-error `true` collides with the string tag `"true"`
getTaggedUnionMatcher<BooleanColliding>()("kind")({ true: () => 1 });
// @ts-expect-error the number tag `1` collides with the string tag `"1"`
getTaggedUnionMatcher<NumericColliding>()("kind")({ "1": () => 1 });
// @ts-expect-error the widening factory rejects the collision too
getTaggedUnionMatcherW<BooleanColliding>()("kind")({ true: () => 1 });
});
test("property union: a collision is rejected beside legal members", () => {
// Arrange — the map is otherwise complete, so only the collision can make
// it invalid.
interface Colliding {
kind: "true" | true | "a";
a: number;
}
// Act / Assert — the call below must not compile
// @ts-expect-error `true` collides with the string tag `"true"`
getTaggedUnionMatcher<Colliding>()("kind")({ true: () => 1, a: () => 2 });
});
// ============================================================================
// Duplicate tags
// ============================================================================
// A tag is a discriminant only while it is unique across the union. Two members
// may still share one: the tag set `Tags<T, K>` dedupes, so a single handler is
// exhaustive for both and receives their union. This is the documented known
// issue (development/library.md § Tagged-union matcher); the tests below pin the
// behavior so it cannot change silently.
test("duplicate tag: members sharing a tag collapse to one handler", () => {
// Arrange — `kind` is not a true discriminant: both members carry `"a"`.
interface First {
readonly kind: "a";
readonly first: number;
}
interface Second {
readonly kind: "a";
readonly second: string;
}
type Clashing = First | Second;
const factory = getTaggedUnionMatcher<Clashing>()("kind");
// Act
const pick = factory({
a: (s) => {
// The shared tag cannot be split, so the handler sees both members.
expectTypeOf(s).toEqualTypeOf<First | Second>();
assert.equal(s.kind, "a");
return "first" in s ? s.first : s.second.length;
},
});
// Assert — the one `a` key is exhaustive and both members reach it.
expectTypeOf(pick).toEqualTypeOf<(shape: Clashing) => number>();
assert.equal(pick({ kind: "a", first: 1 }), 1);
assert.equal(pick({ kind: "a", second: "abc" }), 3);
});
test("duplicate tag: handling one tag consumes every member that shares it", () => {
// Arrange — `"a"` selects two members; `"c"` selects one.
type Mixed =
| { readonly kind: "a"; readonly a: number }
| { readonly kind: "a"; readonly b: string }
| { readonly kind: "c"; readonly c: boolean };
const factory = getTaggedUnionMatcher<Mixed>()("kind");
// Act
const describe = factory(
{
a: (s) => {
expectTypeOf(s).toEqualTypeOf<
| { readonly kind: "a"; readonly a: number }
| { readonly kind: "a"; readonly b: string }
>();
return 1 as const;
},
},
(s) => {
// Handling `"a"` removes both of its members, not just one.
expectTypeOf(s).toEqualTypeOf<{
readonly kind: "c";
readonly c: boolean;
}>();
assert.equal(s.kind, "c");
return 2 as const;
},
);
// Assert
expectTypeOf(describe).toEqualTypeOf<(shape: Mixed) => 1 | 2>();
assert.equal(describe({ kind: "a", a: 1 }), 1);
assert.equal(describe({ kind: "a", b: "x" }), 1);
assert.equal(describe({ kind: "c", c: true }), 2);
});
// ============================================================================
// Dispatch — runtime behavior
// ============================================================================
test("getTaggedUnionMatcher: an unhandled tag throws without a fallback", () => {
// Arrange — a finite tag set is exhaustive through the typed API, so the
// defensive throw is reachable only by widening the factory and dropping a
// handler.
interface Message {
readonly kind: "info" | "warn";
readonly text: string;
}
const bypass: Function = getTaggedUnionMatcher<Message>()("kind");
const matcher: (shape: Message) => number = bypass({
info: (shape: Message) => {
// The full member reaches the handler, not its tag.
assert.equal(shape.kind, "info");
return 1;
},
});
// Act / Assert
assert.equal(matcher({ kind: "info", text: "" }), 1);
assert.throws(
() => matcher({ kind: "warn", text: "" }),
/Unhandled tag: warn/,
);
});
// ============================================================================
// Autocomplete — the language server is the oracle, not the type system
// ============================================================================
const REPO_ROOT = path.resolve(import.meta.dirname, "..");
const SHAPE_SOURCE = `type Shape = { kind: "a"; a: number } | { kind: "b"; b: number } | { kind: "c"; c: number };`;
interface LabelsProbe {
readonly name: string;
readonly factory: "getTaggedUnionMatcher" | "getTaggedUnionMatcherW";
readonly body: string;
readonly tail?: string;
readonly typeName?: string;
readonly typeSource?: string;
readonly key?: string;
}
const labelsFor = ({
name,
factory,
body,
tail = "",
typeName = "Shape",
typeSource = SHAPE_SOURCE,
key = "kind",
}: LabelsProbe): Promise<readonly string[]> => {
const session = new LspSession(REPO_ROOT);
const target: CompletionTarget = {
file: `src/__autocomplete_${name}.ts`,
source: [
`import { ${factory} } from "./index.ts";`,
typeSource,
`const m = ${factory}<${typeName}>()("${key}")({`,
body,
`}${tail});`,
"",
].join("\n"),
};
return session
.completionLabelsAt(target)
.then((result) => result.labels)
.finally(() => session.close());
};
test("autocomplete: a tagged-union pattern requires the tags", () => {
// Arrange
const name = "tagged_union_fresh";
// Act
const labels = labelsFor({
name,
factory: "getTaggedUnionMatcher",
body: " /*COMPLETE*/",
});
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["a", "b", "c"]);
});
});
test("autocomplete: a handled tag drops out of the popup", () => {
// Arrange
const name = "tagged_union_after_key";
// Act
const labels = labelsFor({
name,
factory: "getTaggedUnionMatcher",
body: " a: () => 1,\n /*COMPLETE*/",
});
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["b", "c"]);
});
});
test("autocomplete: a fallback makes the remaining tags optional", () => {
// Arrange
const name = "tagged_union_with_fallback";
// Act
const labels = labelsFor({
name,
factory: "getTaggedUnionMatcher",
body: " /*COMPLETE*/",
tail: ", () => 0",
});
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["a?", "b?", "c?"]);
});
});
// `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"]);
});
});
// A single shape whose property is a union: the popup is still keyed by the
// property's values, not by the members of a union type.
const PALETTE_SOURCE = `interface Palette { color: "red" | "green" | "blue"; value: number }`;
const FLAG_SOURCE = `interface Flag { kind: true | false | null | undefined; a: number }`;
test("property union: autocomplete offers the property's values", () => {
// Arrange
const name = "property_union_fresh";
// Act
const labels = labelsFor({
name,
factory: "getTaggedUnionMatcher",
typeName: "Palette",
typeSource: PALETTE_SOURCE,
key: "color",
body: " /*COMPLETE*/",
});
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["blue", "green", "red"]);
});
});
test("property union: autocomplete drops a handled value", () => {
// Arrange
const name = "property_union_after_key";
// Act
const labels = labelsFor({
name,
factory: "getTaggedUnionMatcher",
typeName: "Palette",
typeSource: PALETTE_SOURCE,
key: "color",
body: " red: () => 1,\n /*COMPLETE*/",
});
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["blue", "green"]);
});
});
test("property union: autocomplete makes the remaining values optional with a fallback", () => {
// Arrange
const name = "property_union_with_fallback";
// Act
const labels = labelsFor({
name,
factory: "getTaggedUnionMatcher",
typeName: "Palette",
typeSource: PALETTE_SOURCE,
key: "color",
body: " /*COMPLETE*/",
tail: ", () => 0",
});
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["blue?", "green?", "red?"]);
});
});
test("property union: autocomplete offers boolean and nullish values by name", () => {
// Arrange
const name = "property_union_boolean_nullish";
// Act
const labels = labelsFor({
name,
factory: "getTaggedUnionMatcher",
typeName: "Flag",
typeSource: FLAG_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 };`;
const discriminantLabelsFor = ({
name,
factory,
typeName,
typeSource,
}: {
readonly name: string;
readonly factory: "getTaggedUnionMatcher" | "getTaggedUnionMatcherW";
readonly typeName: string;
readonly typeSource: string;
}): Promise<readonly string[]> => {
const session = new LspSession(REPO_ROOT);
const target: CompletionTarget = {
file: `src/__autocomplete_${name}.ts`,
source: [
`import { ${factory} } from "./index.ts";`,
typeSource,
`const m = ${factory}<${typeName}>()(/*COMPLETE*/);`,
"",
].join("\n"),
};
return session
.completionLabelsAt(target)
.then((result) => result.labels)
.finally(() => session.close());
};
test("autocomplete: the discriminant key is offered at the key argument", () => {
// Arrange
const name = "tagged_union_discriminant";
// Act
const labels = discriminantLabelsFor({
name,
factory: "getTaggedUnionMatcher",
typeName: "Mixed",
typeSource: MIXED_SOURCE,
});
// Assert
return labels.then((result) => {
// The argument position also offers every global identifier, so assert
// inclusion of the discriminant and exclusion of the non-tag key.
assert.ok(result.includes('"kind"'));
assert.ok(!result.includes('"id"'));
});
});