diff --git a/backlog.tasks b/backlog.tasks index 0d80323..e457520 100644 --- a/backlog.tasks +++ b/backlog.tasks @@ -63,6 +63,7 @@ Matcher: ☐ when using a union type as a property, the current behavior of tagged union matcher is 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 Bugs: ✔ TS 7 LSP server logs `context canceled` on stderr at shutdown @done @@ -72,8 +73,8 @@ Enhancements: ✔ Allow boolean literals in primitive union patterns (e.g. `true: () => "yes"`) @medium @done ✔ Are there other primitive types that should be supported in union patterns? (e.g. `bigint`, `symbol`) @medium @done → added boolean, null and undefined; rejected `symbol` (compile-time brand, nothing at runtime) and `bigint` (not a property key) -☐ Allow boolean, null and undefined discriminant values in tagged-union patterns @medium - → needs the primitive-union matcher's `PatternKey` / `PatternParam` projection; see development/library.md § Tagged-union matcher +✔ Allow boolean, null and undefined discriminant values in tagged-union patterns @medium @done + → moved the `PatternKey` / `PatternParam` projection to `matcher-shared.ts` and keyed the tagged-union handler map through it; see development/library.md § Tagged-union matcher 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 4b0698a..9196b90 100644 --- a/development/library.md +++ b/development/library.md @@ -97,22 +97,29 @@ Each factory is two overloads whose order is load-bearing: #### Decision (2026-09) -`src/matcher-shared.ts` holds the four universe-agnostic pieces both matchers -use: `UnaryFn`, `PatternReturns`, `RedundantFallback`, `HandlerMap`. +`src/matcher-shared.ts` holds the seven universe-agnostic pieces both matchers +use: `UnaryFn`, `PatternReturns`, `RedundantFallback`, `HandlerMap`, the shared +`Matchable` universe, and the `PatternKey` / `PatternParam` key projection. #### Why - `RedundantFallback`'s property name is the diagnostic, so one definition - keeps the two matchers' message from drifting; the other three appear verbatim - in both public signatures. + keeps the two matchers' message from drifting; the other pieces appear + verbatim in both public signatures or are the same projection over each + matcher's universe. +- **`Matchable` is one definition, not two.** The primitive-union matcher's + universe and the tagged-union matcher's allowed `Tag` values are the same set, + so aliasing them keeps the two matchers from drifting apart on what they + accept (`symbol`/`bigint` rejected once). #### Rejected - **A generic `Matcher` over the interface pair, `Handlers`, `Fallback` and `MustBePartial`.** Each is built from its own universe - (`PatternKey`/`PatternParam` vs `Tags`/`MapTaggedUnion`); abstracting over the + (`Tags`/`MapTaggedUnion` vs the primitive values); abstracting over the F-bounded `Handled` constraint that makes the remainder work risks the - contextual typing it exists to preserve. + contextual typing it exists to preserve. `Matchable` and the `PatternKey` / + `PatternParam` projection are the pieces both universes genuinely share. ## Tagged-union matcher @@ -152,15 +159,18 @@ The key is a separate call because `K` is inferred from its literal argument and 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. +- **A `boolean` / `null` / `undefined` tag goes through the shared + `PatternKey` / `PatternParam` projection.** `Discriminated` admits those tags + (they are in `Tag`), but they cannot key a mapped type, so the handler map is + keyed by the stringified form (`true` → `"true"`) and `PatternParam` inverts + it to recover the member. This is the same projection the primitive-union + matcher uses over its universe, which is why it lives in `matcher-shared.ts`. #### Known issue -- Tags are `string | number` only. A `boolean` / `null` / `undefined` - discriminant (`{ ok: true } | { ok: false }`) is rejected by `Discriminated`, - because those values are not property keys; supporting them needs the - primitive-union matcher's `PatternKey` / `PatternParam` projection. - A member's tag must be unique across the union; two members with the same tag - collapse to a union under one handler. + collapse to a union under one handler. The same holds for a tag colliding with + its stringification (`true | "true"`) — see [README § Caveats](../README.md#caveats). ## Primitive universe @@ -172,7 +182,9 @@ with `boolean` admitted as `true | false`. `boolean`/`null`/`undefined` are not property keys, so handler-map keys are a projection (`PatternKey`: each member stringified) and `PatternParam` inverts it, so callbacks receive the real member (`true`, not `"true"`). The popup -offers `true`, `false`, `null`, `undefined` by name (verified over LSP). +offers `true`, `false`, `null`, `undefined` by name (verified over LSP). The +same projection is shared with the tagged-union matcher; see +§ Tagged-union matcher. #### Why diff --git a/src/matcher-shared.ts b/src/matcher-shared.ts index 767c543..9c302fc 100644 --- a/src/matcher-shared.ts +++ b/src/matcher-shared.ts @@ -8,6 +8,38 @@ import type { ValueOf } from "type-fest"; // A handler: one universe member in, one return value out. export type UnaryFn = (shape: T) => R; +// The primitive universe a matcher can discriminate. The primitive-union matcher +// uses it directly; the tagged-union matcher uses it as the set of allowed +// discriminant (`Tag`) values. `boolean` is admitted as the pair `true | false`; +// see README § Caveats for the unsupported members. +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. +export type PatternKey = T extends boolean + ? T extends true + ? "true" + : "false" + : T extends null + ? "null" + : 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; + // `Extract` drops optional handlers (`undefined`) so `PatternReturns` also works // when `P`'s constraint has optional keys. export type PatternReturns

= ReturnType< diff --git a/src/primitive-union.ts b/src/primitive-union.ts index afd4a01..7c6061b 100644 --- a/src/primitive-union.ts +++ b/src/primitive-union.ts @@ -2,38 +2,14 @@ import type { Exact } from "type-fest"; import type { HandlerMap, + Matchable, + PatternKey, + PatternParam, PatternReturns, RedundantFallback, UnaryFn, } from "./matcher-shared.ts"; -// The primitive universe a matcher can discriminate. `boolean` is admitted as -// the pair `true | false`; see README § Caveats for the unsupported members. -type Matchable = string | number | boolean | null | undefined; - -// `boolean`, `null` and `undefined` cannot be property keys, so a mapped type -// over the universe keys each non-key member by its stringification. `Param` -// inverts that projection, so a handler callback still receives the *real* -// member (`true`, not `"true"`) — see README § Caveats for the limits. -type PatternKey = T extends boolean - ? T extends true - ? "true" - : "false" - : T extends null - ? "null" - : T extends undefined - ? "undefined" - : T; -type PatternParam = K extends "true" - ? true - : K extends "false" - ? false - : K extends "null" - ? null - : K extends "undefined" - ? undefined - : K; - type Handlers = { [K in PatternKey]: UnaryFn, R>; }; @@ -64,11 +40,10 @@ type MustBePartial = // so `Exact` restores it for the generic forms: a handler map can otherwise // carry keys outside `T`. -// 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 MatcherStrict { +interface PrimitiveUnionMatcherStrict { (handlers: Handlers): UnaryFn; < R, @@ -79,13 +54,11 @@ interface MatcherStrict { 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 MatcherWidening { +interface PrimitiveUnionMatcherWidening {

, P>>( handlers: P, ): UnaryFn>; @@ -98,7 +71,6 @@ interface MatcherWidening { fallback: Fallback, ): UnaryFn | R>; } -// oxlint-enable typescript/unified-signatures const dispatch = (handlers: HandlerMap, fallback?: UnaryFn) => @@ -125,7 +97,7 @@ const dispatch = export const getPrimitiveUnionMatcher = < T extends Matchable, ->(): MatcherStrict => dispatch; +>(): PrimitiveUnionMatcherStrict => dispatch; export const getPrimitiveUnionMatcherW = < T extends Matchable, ->(): MatcherWidening => dispatch; +>(): PrimitiveUnionMatcherWidening => dispatch; diff --git a/src/tagged-union.test.ts b/src/tagged-union.test.ts index 2027f71..7873b81 100644 --- a/src/tagged-union.test.ts +++ b/src/tagged-union.test.ts @@ -100,6 +100,147 @@ test("getTaggedUnionMatcher: dispatches on numeric tags", () => { 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()("status"); + + // Act + const pick = factory({ + true: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s.status, true); + return s.value; + }, + false: (s) => { + expectTypeOf(s).toEqualTypeOf(); + 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()("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()("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()("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 // ============================================================================ @@ -260,6 +401,26 @@ test("getTaggedUnionMatcher factory rejects a non-discriminant key", () => { getTaggedUnionMatcher()("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()("status"); + const maybeFactory = getTaggedUnionMatcher()("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()("kind"); @@ -321,6 +482,8 @@ interface LabelsProbe { readonly factory: "getTaggedUnionMatcher" | "getTaggedUnionMatcherW"; readonly body: string; readonly tail?: string; + readonly typeName?: string; + readonly typeSource?: string; } const labelsFor = ({ @@ -328,14 +491,16 @@ const labelsFor = ({ factory, body, tail = "", + typeName = "Shape", + typeSource = SHAPE_SOURCE, }: 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")({`, + typeSource, + `const m = ${factory}<${typeName}>()("kind")({`, body, `}${tail});`, "", @@ -399,6 +564,29 @@ test("autocomplete: a fallback makes the remaining tags optional", () => { }); }); +// `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"]); + }); +}); + // `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 };`; diff --git a/src/tagged-union.ts b/src/tagged-union.ts index 16f6ab4..f501cc3 100644 --- a/src/tagged-union.ts +++ b/src/tagged-union.ts @@ -2,46 +2,53 @@ import type { Exact, UnknownRecord } from "type-fest"; import type { HandlerMap, + Matchable, + PatternKey, + PatternParam, PatternReturns, RedundantFallback, UnaryFn, } from "./matcher-shared.ts"; -// 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; +// A tagged union is discriminated by one property whose values are the tags (a +// `Matchable`). `string` and `number` tags key a handler map directly; +// `boolean`, `null` and `undefined` are admitted too but are not property keys, +// so they go through the `PatternKey` projection. `symbol` has no literal syntax +// 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. -type Tags = Extract; +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; + [K in keyof T]: T[K] extends Matchable ? 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. +// Keyed by `PatternKey`, so `boolean`/`null`/`undefined` tags can key a mapped +// type; `PatternParam` inverts the projection to recover the member. type MapTaggedUnion = { - [V in Tags]: Extract>; + [P in PatternKey>]: Extract>>; }; type Handlers = { - [V in Tags]: UnaryFn[V], R>; + [P in PatternKey>]: UnaryFn[P], R>; }; -// 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-union matcher's `Exclude>`. +// The members `Handled` covers. Mapping over the projected keys 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-union matcher's +// `Exclude>`. type HandledMembers = { - [V in Tags]: V extends keyof Handled - ? MapTaggedUnion[V] + [P in PatternKey>]: P extends keyof Handled + ? MapTaggedUnion[P] : never; -}[Tags]; +}[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. @@ -55,7 +62,7 @@ type Fallback = UnaryFn< // inference; see the primitive-union matcher for why a conditional in the fallback's // parameter is evaluated too early. type MustBePartial = - Tags extends keyof Handled ? RedundantFallback : unknown; + PatternKey> 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. @@ -92,13 +99,26 @@ interface TaggedUnionMatcherWidening { ): UnaryFn | R>; } +// The key-taking step of the curried factory. Naming it lets the factory return +// `dispatch` directly, the tacit twin of the primitive-union factory's bare +// `=> dispatch`. +type TaggedUnionMatcherFactory = >( + k: K, +) => TaggedUnionMatcherStrict; + +type TaggedUnionMatcherWideningFactory = < + K extends Discriminated, +>( + k: K, +) => TaggedUnionMatcherWidening; + const dispatch = (k: PropertyKey) => (handlers: HandlerMap, fallback?: UnaryFn) => (shape: object): unknown => { // `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. + // the factory admits only keys whose values are tags, and the map keys + // them by `PatternKey`, so the result is narrowed to the map's key space. // oxlint-disable-next-line typescript/no-unsafe-type-assertion const tag = (shape as UnknownRecord)[k] as string | number; return ( @@ -113,12 +133,10 @@ const dispatch = ); }; -export const getTaggedUnionMatcher = - () => - >(k: K): TaggedUnionMatcherStrict => - dispatch(k); +export const getTaggedUnionMatcher = < + T extends object, +>(): TaggedUnionMatcherFactory => dispatch; -export const getTaggedUnionMatcherW = - () => - >(k: K): TaggedUnionMatcherWidening => - dispatch(k); +export const getTaggedUnionMatcherW = < + T extends object, +>(): TaggedUnionMatcherWideningFactory => dispatch;