1607 lines
51 KiB
TypeScript
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"'));
|
|
});
|
|
});
|