diff --git a/cspell.json b/cspell.json index f0e5dff..35b7457 100644 --- a/cspell.json +++ b/cspell.json @@ -43,7 +43,9 @@ "silverwind", "idris", "todotasks", - "connor" + "connor", + "injective", + "injectivity" ], "ignorePaths": ["dist", "node_modules", "coverage", "*.svg", ".gitignore"] } diff --git a/src/matcher-shared.ts b/src/matcher-shared.ts index 9c302fc..fead4cc 100644 --- a/src/matcher-shared.ts +++ b/src/matcher-shared.ts @@ -1,4 +1,4 @@ -import type { ValueOf } from "type-fest"; +import type { IsLiteral, IsNever, ValueOf } from "type-fest"; // The primitive-union and tagged-union matchers differ in their universe, but the // handler/fallback plumbing is identical; these are the shared pieces. The @@ -16,11 +16,11 @@ export type Matchable = string | number | boolean | null | undefined; // `boolean`, `null` and `undefined` cannot be property keys, so a mapped type // over a universe that includes one keys each such member by its -// stringification. `PatternParam` inverts that projection, so a handler callback -// still receives the *real* member (`true`, not `"true"`). Both matchers use the -// projection: the primitive-union matcher over its universe, the tagged-union -// matcher over a discriminant property's values. See README § Caveats for the -// limits. +// stringification. `Member` inverts that projection against the universe, so a +// handler callback still receives the *real* member (`true`, not `"true"`). +// Both matchers use the projection: the primitive-union matcher over its +// universe, the tagged-union matcher over a discriminant property's values. See +// README § Caveats for the limits. export type PatternKey = T extends boolean ? T extends true ? "true" @@ -30,15 +30,56 @@ export type PatternKey = T extends boolean : T extends undefined ? "undefined" : T; -export type PatternParam = K extends "true" - ? true - : K extends "false" - ? false - : K extends "null" - ? null - : K extends "undefined" - ? undefined - : K; + +// The member(s) of `T` whose `PatternKey` is `K`: the universe-keyed inverse of +// `PatternKey`. The key alone cannot recover the member (`"true"` and `true` +// share it), so the handler parameter is derived from `T` instead. For a +// supported (injective) universe the result is a single member. +export type Member< + T extends Matchable, + K extends PropertyKey, +> = T extends Matchable ? (PatternKey extends K ? T : never) : never; + +// The property key a `Matchable` member takes at runtime: booleans, `null` and +// `undefined` stringify, and a numeric literal becomes its decimal string. +export type Stringified = T extends boolean + ? T extends true + ? "true" + : "false" + : T extends null + ? "null" + : T extends undefined + ? "undefined" + : T extends number + ? `${T}` + : never; + +// The members of `T` that are also the stringification of another member, so +// `PatternKey` cannot invert them. `never` means the universe is injective. +export type Collisions = Extract>; + +// Why `T` is not a supported universe, or `never` when it is. A supported +// universe is a finite union of literals with no value/stringification +// collision: only then can `PatternKey` be inverted unambiguously. +export type UnsupportedReason = + IsLiteral> extends false + ? "broad types like string, number and template literals are not supported" + : [Collisions] extends [never] + ? never + : `a value and its stringification collide. Value is "${Collisions & string}"`; + +// The diagnostic for an unsupported universe. Extends `HandlerMap` so the +// implementation's `handlers: HandlerMap` stays assignable when the gate is +// intersected into a parameter; the property name is the message. +export type UnsupportedUniverse = HandlerMap & + Readonly>; + +// `unknown` for a supported universe (an intersection no-op), the diagnostic +// otherwise. Intersecting rather than branching keeps `R` inference intact. +export type UniverseGate = + IsNever> extends true + ? unknown + : UnsupportedUniverse>; // `Extract` drops optional handlers (`undefined`) so `PatternReturns` also works // when `P`'s constraint has optional keys. diff --git a/src/primitive-union.test.ts b/src/primitive-union.test.ts index f2235a7..17c2ac0 100644 --- a/src/primitive-union.test.ts +++ b/src/primitive-union.test.ts @@ -495,26 +495,392 @@ test("getPrimitiveUnionMatcher factory rejects non-exhaustive boolean and nullis ); }); -test("getPrimitiveUnionMatcher: an open universe keeps the fallback's remainder open", () => { +test("getPrimitiveUnionMatcher: a standalone stringified literal is typed by the member, not its key", () => { + // `"true"` is a string member whose `PatternKey` is `"true"`; inverting the + // key must recover the string, not the boolean that also stringifies to it. // Arrange - const factory = getPrimitiveUnionMatcher(); + const factory = getPrimitiveUnionMatcher<"true" | "1">(); // Act - const matcher = factory({ a: () => 1 }, (s) => { - // The map's literal keys do not close an open universe, so the - // remainder stays `string` and the fallback is not redundant. - expectTypeOf(s).toEqualTypeOf(); - // A strict pattern fixes one `R` for every handler, so this fallback - // cannot return its shape; `typeof` is the strongest claim the value - // makes on its own — any string passes, including `""`. - assert.equal(typeof s, "string"); - return 2 as const; + const matcher = factory({ + true: (s) => { + expectTypeOf(s).toEqualTypeOf<"true">(); + assert.equal(s, "true"); + return 1; + }, + "1": (s) => { + expectTypeOf(s).toEqualTypeOf<"1">(); + assert.equal(s, "1"); + return 2; + }, }); // Assert - expectTypeOf(matcher).toEqualTypeOf<(shape: string) => number>(); - assert.equal(matcher("a"), 1); - assert.equal(matcher("b"), 2); + assert.equal(matcher("true"), 1); + assert.equal(matcher("1"), 2); +}); + +test("getPrimitiveUnionMatcher: a broad universe is rejected", () => { + // Act / Assert — the calls below must not compile + // @ts-expect-error `string` is not a finite union of literals + getPrimitiveUnionMatcher()({ a: () => 1 }); + // @ts-expect-error `number` is not a finite union of literals + getPrimitiveUnionMatcher()({ 1: () => 1 }); + // @ts-expect-error a template literal is open + getPrimitiveUnionMatcher<`a${string}`>()({ a: () => 1 }); + // @ts-expect-error `string | boolean` is open because of `string` + getPrimitiveUnionMatcher()({ true: () => 1 }); + // @ts-expect-error the widening factory rejects broad universes too + getPrimitiveUnionMatcherW()({ a: () => 1 }); +}); + +test("getPrimitiveUnionMatcher: a value/stringification collision is rejected", () => { + // Act / Assert — the calls below must not compile + // @ts-expect-error `true` collides with the string `"true"` + getPrimitiveUnionMatcher<"true" | true>()({ true: () => 1 }); + // @ts-expect-error `false` collides with the string `"false"` + getPrimitiveUnionMatcher<"false" | false>()({ false: () => 1 }); + // @ts-expect-error `null` collides with the string `"null"` + getPrimitiveUnionMatcher<"null" | null>()({ null: () => 1 }); + // @ts-expect-error `undefined` collides with the string `"undefined"` + getPrimitiveUnionMatcher<"undefined" | undefined>()({ + undefined: () => 1, + }); + // @ts-expect-error the number `1` collides with the string `"1"` + getPrimitiveUnionMatcher<1 | "1">()({ 1: () => 1 }); +}); + +test("getPrimitiveUnionMatcher: a collision is rejected beside legal members", () => { + // Act / Assert — the calls below must not compile; the legal members do not + // mask the collision. Each map is otherwise complete, so only the collision + // can make it invalid. + // @ts-expect-error `true` collides with `"true"` beside legal members + getPrimitiveUnionMatcher<"true" | true | "a" | 2>()({ + true: () => 1, + a: () => 2, + 2: () => 3, + }); + // @ts-expect-error `false` collides with `"false"` beside legal members + getPrimitiveUnionMatcher<"false" | false | "a" | 2>()({ + false: () => 1, + a: () => 2, + 2: () => 3, + }); + // @ts-expect-error `null` collides with `"null"` beside legal members + getPrimitiveUnionMatcher<"null" | null | "a" | 2>()({ + null: () => 1, + a: () => 2, + 2: () => 3, + }); + // @ts-expect-error `undefined` collides with `"undefined"` beside legal members + getPrimitiveUnionMatcher<"undefined" | undefined | "a" | 2>()({ + undefined: () => 1, + a: () => 2, + 2: () => 3, + }); + // @ts-expect-error `1` collides with `"1"` beside legal members + getPrimitiveUnionMatcher<1 | "1" | "a" | true>()({ + 1: () => 1, + a: () => 2, + true: () => 3, + }); +}); + +test('getPrimitiveUnionMatcher: `"true"` and a bare `false` are legal together', () => { + // Arrange — `false` stringifies to `"false"`, so it does not collide with + // the string member `"true"`. + const factory = getPrimitiveUnionMatcher<"true" | false | "a" | 2>(); + + // Act + const matcher = factory({ + true: (s) => { + expectTypeOf(s).toEqualTypeOf<"true">(); + assert.equal(s, "true"); + return 1; + }, + false: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s, false); + return 2; + }, + a: (s) => { + expectTypeOf(s).toEqualTypeOf<"a">(); + assert.equal(s, "a"); + return 3; + }, + 2: (s) => { + expectTypeOf(s).toEqualTypeOf<2>(); + assert.equal(s, 2); + return 4; + }, + }); + + // Assert + assert.equal(matcher("true"), 1); + assert.equal(matcher(false), 2); + assert.equal(matcher("a"), 3); + assert.equal(matcher(2), 4); +}); + +test('getPrimitiveUnionMatcher: `"false"` and a bare `true` are legal together', () => { + // Arrange — `true` stringifies to `"true"`, not `"false"`. + const factory = getPrimitiveUnionMatcher<"false" | true | "a" | 2>(); + + // Act + const matcher = factory({ + false: (s) => { + expectTypeOf(s).toEqualTypeOf<"false">(); + assert.equal(s, "false"); + return 1; + }, + true: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s, true); + return 2; + }, + a: (s) => { + expectTypeOf(s).toEqualTypeOf<"a">(); + assert.equal(s, "a"); + return 3; + }, + 2: (s) => { + expectTypeOf(s).toEqualTypeOf<2>(); + assert.equal(s, 2); + return 4; + }, + }); + + // Assert + assert.equal(matcher("false"), 1); + assert.equal(matcher(true), 2); + assert.equal(matcher("a"), 3); + assert.equal(matcher(2), 4); +}); + +test('getPrimitiveUnionMatcher: `"null"` and a bare `undefined` are legal together', () => { + // Arrange — `undefined` stringifies to `"undefined"`, not `"null"`. + const factory = getPrimitiveUnionMatcher<"null" | undefined | "a" | 2>(); + + // Act + const matcher = factory({ + null: (s) => { + expectTypeOf(s).toEqualTypeOf<"null">(); + assert.equal(s, "null"); + return 1; + }, + undefined: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s, undefined); + return 2; + }, + a: (s) => { + expectTypeOf(s).toEqualTypeOf<"a">(); + assert.equal(s, "a"); + return 3; + }, + 2: (s) => { + expectTypeOf(s).toEqualTypeOf<2>(); + assert.equal(s, 2); + return 4; + }, + }); + + // Assert + assert.equal(matcher("null"), 1); + assert.equal(matcher(undefined), 2); + assert.equal(matcher("a"), 3); + assert.equal(matcher(2), 4); +}); + +test('getPrimitiveUnionMatcher: `"undefined"` and a bare `null` are legal together', () => { + // Arrange — `null` stringifies to `"null"`, not `"undefined"`. + const factory = getPrimitiveUnionMatcher<"undefined" | null | "a" | 2>(); + + // Act + const matcher = factory({ + undefined: (s) => { + expectTypeOf(s).toEqualTypeOf<"undefined">(); + assert.equal(s, "undefined"); + return 1; + }, + null: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s, null); + return 2; + }, + a: (s) => { + expectTypeOf(s).toEqualTypeOf<"a">(); + assert.equal(s, "a"); + return 3; + }, + 2: (s) => { + expectTypeOf(s).toEqualTypeOf<2>(); + assert.equal(s, 2); + return 4; + }, + }); + + // Assert + assert.equal(matcher("undefined"), 1); + assert.equal(matcher(null), 2); + assert.equal(matcher("a"), 3); + assert.equal(matcher(2), 4); +}); + +test('getPrimitiveUnionMatcher: `"1"` beside other numbers and strings is legal', () => { + // Arrange — the task's example: `"1"` is the string member, `2` and `3` + // are bare numbers, `"4"` is another string. No stringification lands on + // another member. + const factory = getPrimitiveUnionMatcher<"1" | 2 | 3 | "4">(); + + // Act + const matcher = factory({ + "1": (s) => { + expectTypeOf(s).toEqualTypeOf<"1">(); + assert.equal(s, "1"); + return 1; + }, + 2: (s) => { + expectTypeOf(s).toEqualTypeOf<2>(); + assert.equal(s, 2); + return 2; + }, + 3: (s) => { + expectTypeOf(s).toEqualTypeOf<3>(); + assert.equal(s, 3); + return 3; + }, + "4": (s) => { + expectTypeOf(s).toEqualTypeOf<"4">(); + assert.equal(s, "4"); + return 4; + }, + }); + + // Assert + assert.equal(matcher("1"), 1); + assert.equal(matcher(2), 2); + assert.equal(matcher(3), 3); + assert.equal(matcher("4"), 4); +}); + +// The maximum legal universe needs one handler per member; the statement count +// is inherent to the case. +// oxlint-disable-next-line max-statements +test("getPrimitiveUnionMatcher: the maximum legal universe is supported", () => { + // Arrange — every Matchable kind, one representation per value, plus extra + // literals. No member's stringification is another member. + type Legal = + | "true" + | false + | null + | undefined + | "1" + | 2 + | 3 + | "a" + | "b" + | "4"; + const factory = getPrimitiveUnionMatcher(); + + // Act + const matcher = factory({ + true: (s) => { + expectTypeOf(s).toEqualTypeOf<"true">(); + assert.equal(s, "true"); + return 1; + }, + false: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s, false); + return 2; + }, + null: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s, null); + return 3; + }, + undefined: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s, undefined); + return 4; + }, + "1": (s) => { + expectTypeOf(s).toEqualTypeOf<"1">(); + assert.equal(s, "1"); + return 5; + }, + 2: (s) => { + expectTypeOf(s).toEqualTypeOf<2>(); + assert.equal(s, 2); + return 6; + }, + 3: (s) => { + expectTypeOf(s).toEqualTypeOf<3>(); + assert.equal(s, 3); + return 7; + }, + a: (s) => { + expectTypeOf(s).toEqualTypeOf<"a">(); + assert.equal(s, "a"); + return 8; + }, + b: (s) => { + expectTypeOf(s).toEqualTypeOf<"b">(); + assert.equal(s, "b"); + return 9; + }, + "4": (s) => { + expectTypeOf(s).toEqualTypeOf<"4">(); + assert.equal(s, "4"); + return 10; + }, + }); + + // Assert + expectTypeOf(matcher).toEqualTypeOf<(shape: Legal) => number>(); + assert.equal(matcher("true"), 1); + assert.equal(matcher(false), 2); + assert.equal(matcher(null), 3); + assert.equal(matcher(undefined), 4); + assert.equal(matcher("1"), 5); + assert.equal(matcher(2), 6); + assert.equal(matcher(3), 7); + assert.equal(matcher("a"), 8); + assert.equal(matcher("b"), 9); + assert.equal(matcher("4"), 10); +}); + +test("getPrimitiveUnionMatcher: the maximum illegal universe is rejected", () => { + // Arrange — every value paired with its stringification. + type Illegal = + | "true" + | true + | "false" + | false + | "null" + | null + | "undefined" + | undefined + | "1" + | 1; + + // Act / Assert — the call below must not compile + // @ts-expect-error every value collides with its stringification + getPrimitiveUnionMatcher()({ + true: () => 1, + false: () => 2, + null: () => 3, + undefined: () => 4, + "1": () => 5, + }); +}); + +test("getPrimitiveUnionMatcher: an open universe is rejected with a fallback too", () => { + // Act / Assert — the call below must not compile; a broad universe is + // unsupported whether or not a fallback is supplied. + // @ts-expect-error `string` is not a finite union of literals + getPrimitiveUnionMatcher()({ a: () => 1 }, () => 2); }); test("getPrimitiveUnionMatcherW factory rejects patterns outside its contract", () => { @@ -539,16 +905,11 @@ test("getPrimitiveUnionMatcherW factory rejects patterns outside its contract", // ============================================================================ test("getPrimitiveUnionMatcher: an unhandled shape throws without a fallback", () => { - // Arrange — an open universe types its handler map as an index signature, - // so the type system cannot prove the runtime map is exhaustive. - const handlers: Record number> = { - a: (shape) => { - // The raw shape reaches the handler, not the property key's text. - assert.equal(shape, "a"); - return 1; - }, - }; - const matcher = getPrimitiveUnionMatcher()(handlers); + // Arrange — a finite universe is exhaustive through the typed API, so the + // defensive throw is reachable only by widening the factory and dropping a + // handler. + const bypass: Function = getPrimitiveUnionMatcher<"a" | "b">(); + const matcher: (shape: "a" | "b") => number = bypass({ a: () => 1 }); // Act / Assert assert.equal(matcher("a"), 1); @@ -556,17 +917,15 @@ test("getPrimitiveUnionMatcher: an unhandled shape throws without a fallback", ( }); test("getPrimitiveUnionMatcher: an unhandled boolean shape throws without a fallback", () => { - // Arrange — an `string | boolean` universe widens its handler map to an - // index signature, so the runtime map's exhaustiveness is not provable. - const handlers: Record number> = { - true: (shape) => { - // `true` indexes the map as the property `"true"`, but the handler - // is still called with the boolean. + // Arrange — as above; `true` indexes the map as the property `"true"`, but + // the handler is still called with the boolean. + const bypass: Function = getPrimitiveUnionMatcher(); + const matcher: (shape: boolean) => number = bypass({ + true: (shape: boolean) => { assert.equal(shape, true); return 1; }, - }; - const matcher = getPrimitiveUnionMatcher()(handlers); + }); // Act / Assert assert.equal(matcher(true), 1); diff --git a/src/primitive-union.ts b/src/primitive-union.ts index 7c6061b..f3b328b 100644 --- a/src/primitive-union.ts +++ b/src/primitive-union.ts @@ -3,25 +3,26 @@ import type { Exact } from "type-fest"; import type { HandlerMap, Matchable, + Member, PatternKey, - PatternParam, PatternReturns, RedundantFallback, UnaryFn, + UniverseGate, } from "./matcher-shared.ts"; type Handlers = { - [K in PatternKey]: UnaryFn, R>; + [K in PatternKey]: UnaryFn, R>; }; // The fallback is a *second argument*, not a property of the handler map, -// because its parameter is the remainder `Exclude` and TypeScript +// because its parameter is the remainder `Exclude>` and TypeScript // fixes a property's contextual type before it infers its sibling keys. A later // argument, by contrast, is contextually typed from inference on an earlier // one, so the split is what makes the remainder expressible at all. // See development/library.md. type Fallback = UnaryFn< - Exclude>, + Exclude>, R >; @@ -44,14 +45,14 @@ type MustBePartial = // #1 Handlers (first) -> the exhaustive form and the autocomplete popup // #2 Fallback (last) -> accepts a partial handler map plus a fallback interface PrimitiveUnionMatcherStrict { - (handlers: Handlers): UnaryFn; + (handlers: Handlers & UniverseGate): UnaryFn; < R, Handled extends Exact>, Handled> & MustBePartial, >( - handlers: Handled & Partial>, - fallback: Fallback, + handlers: Handled & Partial> & UniverseGate, + fallback: Fallback & UniverseGate, ): UnaryFn; } @@ -60,15 +61,15 @@ interface PrimitiveUnionMatcherStrict { // contextual/autocomplete type. interface PrimitiveUnionMatcherWidening {

, P>>( - handlers: P, + handlers: P & UniverseGate, ): UnaryFn>; < R, Handled extends Exact>, Handled> & MustBePartial, >( - handlers: Handled, - fallback: Fallback, + handlers: Handled & UniverseGate, + fallback: Fallback & UniverseGate, ): UnaryFn | R>; } diff --git a/src/tagged-union.test.ts b/src/tagged-union.test.ts index 7873b81..cadb6ed 100644 --- a/src/tagged-union.test.ts +++ b/src/tagged-union.test.ts @@ -273,27 +273,17 @@ test("getTaggedUnionMatcher: a fallback receives the unhandled members", () => { assert.equal(area({ kind: "triangle", base: 1, height: 1 }), 2); }); -test("getTaggedUnionMatcher: an open discriminant keeps the fallback open", () => { +test("getTaggedUnionMatcher: an open discriminant is rejected with a fallback too", () => { // 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); + // 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()("kind")({ info: () => 1 }, () => 2); }); // ============================================================================ @@ -446,28 +436,489 @@ test("getTaggedUnionMatcherW factory rejects patterns outside its contract", () ); }); +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()("kind"); + + // Act + const matcher = factory({ + true: (s) => { + expectTypeOf(s).toEqualTypeOf(); + 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()("kind")({ a: () => 1 }); + // @ts-expect-error `number` tags are not a finite union of literals + getTaggedUnionMatcher()("kind")({ 1: () => 1 }); + // @ts-expect-error the widening factory rejects broad tags too + getTaggedUnionMatcherW()("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()("kind")({ true: () => 1 }); + // @ts-expect-error the number tag `1` collides with the string tag `"1"` + getTaggedUnionMatcher()("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()("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()("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()("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()("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()("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()("kind")({ + true: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s.kind, "true"); + return 1; + }, + false: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s.kind, false); + return 2; + }, + a: (s) => { + expectTypeOf(s).toEqualTypeOf(); + 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()("kind")({ + false: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s.kind, "false"); + return 1; + }, + true: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s.kind, true); + return 2; + }, + a: (s) => { + expectTypeOf(s).toEqualTypeOf(); + 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()("kind")({ + null: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s.kind, "null"); + return 1; + }, + undefined: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s.kind, undefined); + return 2; + }, + a: (s) => { + expectTypeOf(s).toEqualTypeOf(); + 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()("kind")({ + undefined: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s.kind, "undefined"); + return 1; + }, + null: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s.kind, null); + return 2; + }, + a: (s) => { + expectTypeOf(s).toEqualTypeOf(); + 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()("kind")({ + "1": (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s.kind, "1"); + return 1; + }, + 2: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s.kind, 2); + return 2; + }, + 3: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s.kind, 3); + return 3; + }, + "4": (s) => { + expectTypeOf(s).toEqualTypeOf(); + 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()("kind")({ + true: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s.kind, "true"); + return 1; + }, + false: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s.kind, false); + return 2; + }, + null: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s.kind, null); + return 3; + }, + undefined: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s.kind, undefined); + return 4; + }, + "1": (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s.kind, "1"); + return 5; + }, + 2: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s.kind, 2); + return 6; + }, + 3: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s.kind, 3); + return 7; + }, + a: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s.kind, "a"); + return 8; + }, + b: (s) => { + expectTypeOf(s).toEqualTypeOf(); + 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()("kind")({ + true: () => 1, + false: () => 2, + null: () => 3, + undefined: () => 4, + "1": () => 5, + }); +}); + // ============================================================================ // 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. + // 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: string; + readonly kind: "info" | "warn"; + readonly text: string; } - const handlers: Record number> = { - info: (shape) => { + const bypass: Function = getTaggedUnionMatcher()("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; }, - }; - const matcher = getTaggedUnionMatcher()("kind")(handlers); + }); // Act / Assert - assert.equal(matcher({ kind: "info" }), 1); - assert.throws(() => matcher({ kind: "warn" }), /Unhandled tag: warn/); + assert.equal(matcher({ kind: "info", text: "" }), 1); + assert.throws( + () => matcher({ kind: "warn", text: "" }), + /Unhandled tag: warn/, + ); }); // ============================================================================ diff --git a/src/tagged-union.ts b/src/tagged-union.ts index f501cc3..fabfb07 100644 --- a/src/tagged-union.ts +++ b/src/tagged-union.ts @@ -3,11 +3,12 @@ import type { Exact, UnknownRecord } from "type-fest"; import type { HandlerMap, Matchable, + Member, PatternKey, - PatternParam, PatternReturns, RedundantFallback, UnaryFn, + UniverseGate, } from "./matcher-shared.ts"; // A tagged union is discriminated by one property whose values are the tags (a @@ -17,8 +18,8 @@ import type { // to write a handler under, and `bigint` is not a property key. // 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. +// tags and leaves a widened `string`/`number` as itself; a broad tag is then +// rejected by the universe gate. type Tags = Extract; // The keys of `T` that can act as a discriminant. `getTaggedUnionMatcher()` @@ -30,9 +31,10 @@ type Discriminated = { // The member(s) of `T` tagged `V`. `Extract` distributes over the union, so a // duplicated tag maps to a union of members rather than silently dropping one. // Keyed by `PatternKey`, so `boolean`/`null`/`undefined` tags can key a mapped -// type; `PatternParam` inverts the projection to recover the member. +// type; `Member` inverts the projection against the tag set to recover the +// member. type MapTaggedUnion = { - [P in PatternKey>]: Extract>>; + [P in PatternKey>]: Extract, P>>>; }; type Handlers = { @@ -43,7 +45,7 @@ type Handlers = { // 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-union matcher's -// `Exclude>`. +// `Exclude>`. type HandledMembers = { [P in PatternKey>]: P extends keyof Handled ? MapTaggedUnion[P] @@ -51,7 +53,7 @@ type HandledMembers = { }[PatternKey>]; // 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. +// its parameter can be the remainder the map left uncovered. See development/library.md. type Fallback = UnaryFn< Exclude>, R @@ -71,14 +73,16 @@ type MustBePartial = // #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; + (handlers: Handlers & UniverseGate>): UnaryFn; < R, Handled extends Exact>, Handled> & MustBePartial, >( - handlers: Handled & Partial>, - fallback: Fallback, + handlers: Handled & + Partial> & + UniverseGate>, + fallback: Fallback & UniverseGate>, ): UnaryFn; } @@ -87,15 +91,15 @@ interface TaggedUnionMatcherStrict { // contextual/autocomplete type. interface TaggedUnionMatcherWidening {

, P>>( - handlers: P, + handlers: P & UniverseGate>, ): UnaryFn>; < R, Handled extends Exact>, Handled> & MustBePartial, >( - handlers: Handled, - fallback: Fallback, + handlers: Handled & UniverseGate>, + fallback: Fallback & UniverseGate>, ): UnaryFn | R>; }