From 1bdf7f0315eeaf2dc06b1cc3bf064e4c423cff9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Wed, 23 Sep 2026 16:32:09 +0000 Subject: [PATCH] :bug: Reject mixed literal and broad universes `UnsupportedReason` tested `IsLiteral> extends false`. `IsLiteral` is `boolean` for a union that mixes a literal with a broad type (`"a" | \`x-${number}\``), so `boolean extends false` is false and the mix was accepted. Test `extends true` instead, which rejects it. Found while adding property-union broad-universe coverage; the gate is shared with the primitive-union matcher. --- CHANGELOG.md | 2 ++ development/library.md | 4 ++++ src/matcher-shared.ts | 10 +++++----- src/primitive-union.test.ts | 2 ++ 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b14b7c..7c95858 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ 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` diff --git a/development/library.md b/development/library.md index 51bcc04..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. 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