diff --git a/src/index.ts b/src/index.ts index 413625f..442ad4a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,5 @@ export { getMatcher, getMatcherW } from "./primitive.ts"; +export { + getTaggedUnionMatcher, + getTaggedUnionMatcherW, +} from "./tagged-union.ts"; diff --git a/src/tagged-union.test.ts b/src/tagged-union.test.ts new file mode 100644 index 0000000..03b3cdf --- /dev/null +++ b/src/tagged-union.test.ts @@ -0,0 +1,400 @@ +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()("kind"); + + // Act + const area = factory({ + circle: (s) => { + // Each handler receives the member its tag selects, not the union. + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s.kind, "circle"); + return Math.PI * s.radius ** 2; + }, + square: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s.kind, "square"); + return s.side ** 2; + }, + triangle: (s) => { + expectTypeOf(s).toEqualTypeOf(); + 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()("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); +}); + +// ============================================================================ +// API: getTaggedUnionMatcher — ❌ Exhaustive (fallback) / ✔️ ReturnsStrict +// ============================================================================ + +test("getTaggedUnionMatcher: a fallback receives the unhandled members", () => { + // Arrange + const factory = getTaggedUnionMatcher()("kind"); + + // Act + const area = factory( + { + circle: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s.kind, "circle"); + return 1 as const; + }, + }, + (s) => { + // The fallback sees only the members `circle` did not handle. + expectTypeOf(s).toEqualTypeOf(); + 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 keeps the fallback open", () => { + // Arrange + interface Message { + readonly kind: string; + readonly text: string; + } + const factory = getTaggedUnionMatcher()("kind"); + + // Act + const matcher = factory({ info: () => 1 as const }, (s) => { + // The map's literal key does not close an open discriminant, so the + // remainder stays `Message` and the fallback is not redundant. + expectTypeOf(s).toEqualTypeOf(); + assert.equal(typeof s.text, "string"); + return 2 as const; + }); + + // Assert + expectTypeOf(matcher).toEqualTypeOf<(shape: Message) => 1 | 2>(); + assert.equal(matcher({ kind: "info", text: "" }), 1); + assert.equal(matcher({ kind: "warn", text: "" }), 2); +}); + +// ============================================================================ +// API: getTaggedUnionMatcherW — ✔️ Exhaustive / ❌ ReturnsStrict +// ============================================================================ + +test("getTaggedUnionMatcherW: exhaustive pattern widens to the union of returns", () => { + // Arrange + const factory = getTaggedUnionMatcherW()("kind"); + + // Act + const describe = factory({ + circle: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s.kind, "circle"); + return "round" as const; + }, + square: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s.kind, "square"); + return 4 as const; + }, + triangle: (s) => { + expectTypeOf(s).toEqualTypeOf(); + 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()("kind"); + + // Act + const matcher = factory({ circle: () => 1 as const }, (s) => { + expectTypeOf(s).toEqualTypeOf(); + // 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()("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()("id"); + getTaggedUnionMatcher()("kind"); +}); + +test("getTaggedUnionMatcherW factory rejects patterns outside its contract", () => { + // Arrange + const factory = getTaggedUnionMatcherW()("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, + ); +}); + +// ============================================================================ +// Dispatch — runtime behavior +// ============================================================================ + +test("getTaggedUnionMatcher: an unhandled tag throws without a fallback", () => { + // Arrange — an open discriminant widens its handler map to an index + // signature, so the type system cannot prove the runtime map is exhaustive. + interface Message { + readonly kind: string; + } + const handlers: Record number> = { + info: (shape) => { + // The full member reaches the handler, not its tag. + assert.equal(shape.kind, "info"); + return 1; + }, + }; + const matcher = getTaggedUnionMatcher()("kind")(handlers); + + // Act / Assert + assert.equal(matcher({ kind: "info" }), 1); + assert.throws(() => matcher({ kind: "warn" }), /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; +} + +const labelsFor = ({ + name, + factory, + body, + tail = "", +}: LabelsProbe): Promise => { + const session = new LspSession(REPO_ROOT); + const target: CompletionTarget = { + file: `src/__autocomplete_${name}.ts`, + source: [ + `import { ${factory} } from "./index.ts";`, + SHAPE_SOURCE, + `const m = ${factory}()("kind")({`, + 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?"]); + }); +}); diff --git a/src/tagged-union.ts b/src/tagged-union.ts new file mode 100644 index 0000000..978a588 --- /dev/null +++ b/src/tagged-union.ts @@ -0,0 +1,135 @@ +import type { Exact, ValueOf } from "type-fest"; + +type UnaryFn = (shape: T) => R; + +// A tagged union is discriminated by one property whose values are the tags. +// Only `string` and `number` tags can key a handler map: `symbol` has no +// literal syntax to write a handler under, and `bigint` is not a property key. +type Tag = string | number; + +// The discriminant values of `T` under `K`. `Extract` keeps the finite literal +// tags and leaves a widened `string`/`number` as itself, so an open universe +// keeps an open fallback. +type Tags = Extract; + +// The keys of `T` that can act as a discriminant. `getTaggedUnionMatcher()` +// accepts only these, so the factory rejects a key whose values are not tags. +type Discriminated = { + [K in keyof T]: T[K] extends Tag ? K : never; +}[keyof T]; + +// The member(s) of `T` tagged `V`. `Extract` distributes over the union, so a +// duplicated tag maps to a union of members rather than silently dropping one. +type MapTaggedUnion = { + [V in Tags]: Extract>; +}; + +type Handlers = { + [V in Tags]: UnaryFn[V], R>; +}; + +// `Extract` drops optional handlers (`undefined`) so `PatternReturns` also works +// when `P`'s constraint has optional keys. +type PatternReturns

= ReturnType< + Extract, (...args: never[]) => unknown> +>; + +// The members `Handled` covers. Mapping over `Tags` keeps every index within +// `MapTaggedUnion`'s keys, and the conditional drops a stray key outside `T` so +// it cannot widen the remainder. The remainder is `Exclude`, mirroring +// the primitive matcher's `Exclude>`. +type HandledMembers = { + [V in Tags]: V extends keyof Handled + ? MapTaggedUnion[V] + : never; +}[Tags]; + +// The fallback is a *second argument*, not a property of the handler map, so +// its parameter can be the remainder the map left open. See development/library.md. +type Fallback = UnaryFn< + Exclude>, + R +>; + +// A fallback is redundant once the handler map covers every tag of `T`. Folded +// into `Handled`'s own (self-referential) constraint so it is checked *after* +// inference; see the primitive matcher for why a conditional in the fallback's +// parameter is evaluated too early. +interface RedundantFallback { + readonly "every case is already handled, so the fallback is redundant": never; +} +type MustBePartial = + Tags extends keyof Handled ? RedundantFallback : unknown; + +// TypeScript does not apply the excess-property check to a generic constraint, +// so `Exact` restores it for the generic forms. + +// oxlint-disable typescript/unified-signatures +// Strict returns: one common `R`. Overload order is load-bearing: +// #1 Handlers (first) -> the exhaustive form and the autocomplete popup +// #2 Fallback (last) -> accepts a partial handler map plus a fallback +interface TaggedUnionMatcherStrict { + (handlers: Handlers): UnaryFn; + < + R, + Handled extends Exact>, Handled> & + MustBePartial, + >( + handlers: Handled & Partial>, + fallback: Fallback, + ): UnaryFn; +} +// oxlint-enable typescript/unified-signatures + +// Widened returns: the union of every handler's return type. `P` is inferred +// from the whole handler map, whose closed constraint supplies the +// contextual/autocomplete type. +// oxlint-disable typescript/unified-signatures +interface TaggedUnionMatcherWidening { +

, P>>( + handlers: P, + ): UnaryFn>; + < + R, + Handled extends Exact>, Handled> & + MustBePartial, + >( + handlers: Handled, + fallback: Fallback, + ): UnaryFn | R>; +} +// oxlint-enable typescript/unified-signatures + +type HandlerMap = Record | undefined>; + +// `object` carries no index signature, so the read needs the assertion; the +// factory admits only keys whose values are `string | number` tags, so the +// result is narrowed to the map's key space. +const tagAt = (shape: object, k: PropertyKey): string | number => + // oxlint-disable-next-line typescript/no-unsafe-type-assertion + (shape as Record)[k] as string | number; + +const dispatch = + (k: PropertyKey) => + (handlers: HandlerMap, fallback?: UnaryFn) => + (shape: object): unknown => + ( + handlers[tagAt(shape, k)] ?? + fallback ?? + (() => { + throw new Error(`Unhandled tag: ${String(tagAt(shape, k))}`); + }) + )( + // oxlint-disable-next-line typescript/no-unsafe-type-assertion + shape as never, + ); + +export const getTaggedUnionMatcher = + () => + >(k: K): TaggedUnionMatcherStrict => + dispatch(k); + +export const getTaggedUnionMatcherW = + () => + >(k: K): TaggedUnionMatcherWidening => + dispatch(k);