diff --git a/CHANGELOG.md b/CHANGELOG.md index 7591b10..7c95858 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- reject a universe that mixes a literal with a broad type (e.g. + `"a" | \`x-${number}\``), which the finite-literal gate let through +- narrow the tagged-union matcher's handler parameters for a union-valued or + optional discriminant, and its fallback to the unhandled tags, instead of + passing `never` - prune the backlog of completed items and record the TS 7 LSP shutdown workaround in development/testing.md diff --git a/backlog.tasks b/backlog.tasks index d4e1650..33d38a4 100644 --- a/backlog.tasks +++ b/backlog.tasks @@ -17,10 +17,10 @@ v1.0: ☐ Achieve 100% branch coverage on `src/index.ts` Matcher: -☐ when using a union type as a property, the current behavior of tagged union matcher is +✔ when using a union type as a property, the current behavior of tagged union matcher is @done to pass never to handler parameters → new matcher function needed or can be fixed in tagged union matcher - ☐ optional discriminant (`{ type?: "x" }`) is the same hole: the boolean/nullish change now admits the `undefined` tag, so the factory accepts the key, but `Extract>` still passes `never` to both the `x` and `undefined` handlers + ✔ optional discriminant (`{ type?: "x" }`) is the same hole: the boolean/nullish change now admits the `undefined` tag, so the factory accepts the key, but `Extract>` still passes `never` to both the `x` and `undefined` handlers @done Documentation: ☐ Bring README.md back to its previous form — synopsis and examples restored, in the correct place diff --git a/development/library.md b/development/library.md index b7b6869..3254d4a 100644 --- a/development/library.md +++ b/development/library.md @@ -145,6 +145,10 @@ inversion: the handler parameter is the member(s) of `T` whose `PatternKey` is object satisfy the exhaustive overload and reaches the `dispatch` throw. Rejecting at the boundary avoids threading an open/closed branch through `Handlers`, `Fallback` and `MustBePartial`. +- **The finite-literal predicate is `IsLiteral> extends true`.** + `IsLiteral` is `boolean` for a union that mixes a literal with a broad type + (`"a" | \`x-${number}\``), so `extends false`would treat the mix as +supported;`extends true` is the check that rejects it. - **Collisions are rejected, not merged.** `Member` would be sound (the handler gets the union), but the API is one handler per member; rejecting keeps `Member` a singleton and the remainder exact. @@ -208,16 +212,27 @@ The key is a separate call because `K` is inferred from its literal argument and #### Why -- **Same fallback/remainder machinery as the primitive-union matcher.** `HandledMembers` - maps the handled tags to their members and `Exclude` is the fallback's - parameter; the redundant-fallback guard is the same F-bounded constraint. Only - the "universe" changes — `T`'s members instead of primitive values. +- **Same fallback/remainder machinery as the primitive-union matcher.** `HandledTags` + recovers the tag values the map handled (`Member`) and + `Narrowed>` is the fallback's parameter; the + redundant-fallback guard is the same F-bounded constraint. Only the "universe" + changes — `T`'s members instead of primitive values. - **`T extends object`, not `Record`.** An `interface` has no implicit index signature, so the `Record` constraint would reject interface-based unions. The runtime reads the tag off `object` with one assertion, the tagged twin of the primitive-union dispatch's `shape as string | number`. -- **`MapTaggedUnion` distributes with `Extract`.** A duplicated tag yields a - union of members instead of dropping one. +- **`Narrowed` distributes over `T`, narrowing `K` to the tag.** A member whose + `K` cannot take the tag drops out; a duplicated tag yields a union of members + instead of dropping one. `T[K] extends V` returns the exact member (a + discriminated union's declared interface) untouched, so the mapped form only + handles a property that is itself a union. +- **A union-valued or optional discriminant is supported.** A single shape whose + property is a union (`{ color: "red" | "green" | "blue" }`) is narrowed per + handler instead of being passed `never`. Matching a defined tag on an optional + property (`{ type?: "x" }`) proves the key is present, so it becomes required + (`{ type: "x" }`); the `undefined` tag narrows it to `{ type?: never }` under + `exactOptionalPropertyTypes` (absence) or `{ type?: undefined }` when the + property explicitly admits `undefined`. - **A `boolean` / `null` / `undefined` tag goes through the shared `PatternKey` / `Member` projection.** `Discriminated` admits those tags (they are in `Tag`), but they cannot key a mapped type, so the handler map is diff --git a/src/matcher-shared.ts b/src/matcher-shared.ts index fead4cc..ecb1732 100644 --- a/src/matcher-shared.ts +++ b/src/matcher-shared.ts @@ -62,11 +62,11 @@ export type Collisions = Extract>; // 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}"`; + IsLiteral> extends true + ? [Collisions] extends [never] + ? never + : `a value and its stringification collide. Value is "${Collisions & string}"` + : "broad types like string, number and template literals are not supported"; // The diagnostic for an unsupported universe. Extends `HandlerMap` so the // implementation's `handlers: HandlerMap` stays assignable when the gate is diff --git a/src/primitive-union.test.ts b/src/primitive-union.test.ts index 17c2ac0..bd307d4 100644 --- a/src/primitive-union.test.ts +++ b/src/primitive-union.test.ts @@ -528,6 +528,8 @@ test("getPrimitiveUnionMatcher: a broad universe is rejected", () => { getPrimitiveUnionMatcher()({ 1: () => 1 }); // @ts-expect-error a template literal is open getPrimitiveUnionMatcher<`a${string}`>()({ a: () => 1 }); + // @ts-expect-error a literal mixed with a template literal is open + getPrimitiveUnionMatcher<"a" | `x-${number}`>()({ 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 diff --git a/src/tagged-union.test.ts b/src/tagged-union.test.ts index cadb6ed..bd6f4b1 100644 --- a/src/tagged-union.test.ts +++ b/src/tagged-union.test.ts @@ -892,6 +892,358 @@ test("getTaggedUnionMatcher: the maximum illegal tag universe is rejected", () = }); }); +// ============================================================================ +// 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()("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()("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()("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()("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()("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()("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>` 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()("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()("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()("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