From 3c11dc5a0fe2ba7b74c902f0ed3aea270543873a Mon Sep 17 00:00:00 2001 From: prdHugo <48803115+HugoPerard@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:54:31 -0700 Subject: [PATCH 1/9] feat: discriminated UiState union, is() predicate, exhaustive call-site errors --- .changeset/discriminated-ui-state.md | 5 + .oxlintrc.json | 3 +- packages/ui-state/src/index.ts | 112 +++++++++++----------- packages/ui-state/src/index.types.test.ts | 60 ++++++++++++ 4 files changed, 121 insertions(+), 59 deletions(-) create mode 100644 .changeset/discriminated-ui-state.md create mode 100644 packages/ui-state/src/index.types.test.ts diff --git a/.changeset/discriminated-ui-state.md b/.changeset/discriminated-ui-state.md new file mode 100644 index 0000000..e3e70a3 --- /dev/null +++ b/.changeset/discriminated-ui-state.md @@ -0,0 +1,5 @@ +--- +"@bearstudio/ui-state": minor +--- + +Make `getUiState` a discriminated union of `set()` results. `is()` narrows, `match` data is per-status, and `exhaustive()` is a type error to call while statuses remain. diff --git a/.oxlintrc.json b/.oxlintrc.json index 58c1be9..6eef9e4 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -16,8 +16,7 @@ "eslint/no-shadow": "off", "unicorn/consistent-function-scoping": "off", "typescript/no-unnecessary-type-parameters": "off", - "typescript/no-unsafe-type-assertion": "off", - "typescript/no-explicit-any": "off" + "typescript/no-unsafe-type-assertion": "off" }, "ignorePatterns": ["**/dist/**", "**/coverage/**"] } diff --git a/packages/ui-state/src/index.ts b/packages/ui-state/src/index.ts index b4ddb16..20adfa2 100644 --- a/packages/ui-state/src/index.ts +++ b/packages/ui-state/src/index.ts @@ -19,66 +19,64 @@ type UiStateError = null | { type NonExhaustiveError = UiStateError; type ExhaustiveError = UiStateError; -type UiState< - Status extends AvailableStatus, - Data extends Record, -> = { - is: (status: S) => boolean; - state: { - __status: Status; - } & Data; - when: < - S extends Status, - R = React.ReactNode, - SData = Omit< - Extract["state"], { __status: S }>, - "__status" - >, - >( +type DataOf< + T extends { __status: string }, + S extends T["__status"], +> = T extends { __status: S } ? Omit : never; + +type ExcludeStatus< + T extends { __status: string }, + S extends string, +> = T extends { __status: S } ? never : T; + +type MatchResult = { + nonExhaustive: () => React.ReactNode; +} & ([Rest] extends [never] + ? { + exhaustive: () => React.ReactNode; + match: ( + error: ExhaustiveError<"All status are already matched">, + ) => ExhaustiveError<"All status are already matched">; + } + : { + exhaustive: ExhaustiveError<`${Rest["__status"]} is missing to use \`exhaustive()\``>; + match: UiState["match"]; + }); + +type UiState = { + is: ( + status: S, + ) => this is UiState>; + state: T; + when: ( status: S | Array, - handler: (data: SData) => R, + handler: (data: DataOf) => R, ) => R | null; exhaustive: () => ExhaustiveError<`\`exhaustive()\` should be use after \`match\``>; nonExhaustive: () => NonExhaustiveError<`\`nonExhaustive()\` should be use after \`match\``>; - match: < - S extends Status, - SData = Omit< - Extract["state"], { __status: S }>, - "__status" - >, - >( + match: ( status: S | Array, handler: ( - data: SData, + data: DataOf, ) => React.ReactNode | ((...args: ExplicitAny[]) => React.ReactNode), __matched?: boolean, render?: () => | React.ReactNode | ((...args: ExplicitAny[]) => React.ReactNode), - ) => { - nonExhaustive: () => React.ReactNode; - } & (Exclude extends never - ? { - exhaustive: () => React.ReactNode; - match: () => ExhaustiveError<"All status are already matched">; - } - : { - exhaustive: () => ExhaustiveError<`${Exclude} is missing to use \`exhaustive()\``>; - match: UiState, Data>["match"]; - }); + ) => MatchResult>; }; -export const getUiState = < - Status extends AvailableStatus, - Data extends Record, ->( +export const getUiState = ( getState: ( - set: >( + set: < + S extends AvailableStatus, + SData extends Record = Record, + >( status: S, data?: SData, ) => { __status: S } & SData, - ) => { __status: Status } & Data, -): UiState => { + ) => T, +): UiState => { const state = Object.freeze( getState((status, data = {} as ExplicitAny) => { return { @@ -88,23 +86,22 @@ export const getUiState = < }), ); - const isMatching = (status: Status): status is S => + const isMatching = (status: T["__status"]): boolean => status === state.__status; - const isMatchingArray = ( - status: Array, - ): status is Array => status.includes(state.__status); + const isMatchingArray = (status: Array): boolean => + status.includes(state.__status); - const uiState: UiState = { + const uiState: UiState = { state, - is: (status) => { + is: ((status: T["__status"]) => { return state.__status === status; - }, + }) as UiState["is"], when: (status, handler) => { if ( typeof status === "string" ? isMatching(status) - : isMatchingArray(status as Array) + : isMatchingArray(status) ) { return handler(state as ExplicitAny); } @@ -117,13 +114,14 @@ export const getUiState = < !__matched && (typeof status === "string" ? isMatching(status) - : isMatchingArray(status as Array)) + : isMatchingArray(status)) ) { return { exhaustive: () => handler(state as ExplicitAny) as React.ReactNode, - nonExhaustive: () => handler(state as ExplicitAny) as React.ReactNode, - match: (status, _handler) => - uiState.match(status, _handler, true, () => + nonExhaustive: () => + handler(state as ExplicitAny) as React.ReactNode, + match: (nextStatus, nextHandler) => + uiState.match(nextStatus, nextHandler, true, () => handler(uiState.state as ExplicitAny), ), }; @@ -132,8 +130,8 @@ export const getUiState = < return { exhaustive: () => render() as React.ReactNode, nonExhaustive: () => render() as React.ReactNode, - match: (status: Status, handler: ExplicitAny) => - uiState.match(status, handler, __matched, render), + match: (nextStatus: T["__status"], nextHandler: ExplicitAny) => + uiState.match(nextStatus, nextHandler, __matched, render), } as ExplicitAny; }, }; diff --git a/packages/ui-state/src/index.types.test.ts b/packages/ui-state/src/index.types.test.ts new file mode 100644 index 0000000..56ff406 --- /dev/null +++ b/packages/ui-state/src/index.types.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expectTypeOf, expect } from "vitest"; +import { getUiState } from "./index.js"; + +describe("getUiState types", () => { + const cond = true as boolean; + const ui = getUiState((set) => { + if (cond) { + return set("error", { message: "fail" }); + } + return set("default", { title: "ok" }); + }); + + it("state is a discriminated union", () => { + expectTypeOf(ui.state).toEqualTypeOf< + | { __status: "error"; message: string } + | { __status: "default"; title: string } + >(); + }); + + it("is() narrows state", () => { + if (ui.is("error")) { + expectTypeOf(ui.state.message).toEqualTypeOf(); + // @ts-expect-error title is not on error + expect(ui.state.title).toBeUndefined(); + } + }); + + it("__status check narrows state", () => { + if (ui.state.__status === "default") { + expectTypeOf(ui.state.title).toEqualTypeOf(); + // @ts-expect-error message is not on default + expect(ui.state.message).toBeUndefined(); + } + }); + + it("exhaustive() is not callable while statuses remain", () => { + const partial = ui.match("error", () => null); + // @ts-expect-error default is missing + partial.exhaustive(); + }); + + it("exhaustive() is callable when complete", () => { + const done = ui.match("error", () => "e").match("default", () => "d"); + expectTypeOf(done.exhaustive).toEqualTypeOf<() => import("react").ReactNode>(); + expect(done.exhaustive()).toBe("e"); + }); + + it("match data is the variant payload", () => { + ui.match("error", (data) => { + expectTypeOf(data.message).toEqualTypeOf(); + // @ts-expect-error title is not on error payload + return data.title; + }); + }); + + it("is() rejects a status outside the union", () => { + // @ts-expect-error pending is not in the union + expect(ui.is("pending")).toBe(false); + }); +}); From b9dadd754f3bd6139b96aa9482f116b595955430 Mon Sep 17 00:00:00 2001 From: prdHugo <48803115+HugoPerard@users.noreply.github.com> Date: Wed, 26 Aug 2026 04:02:14 -0700 Subject: [PATCH 2/9] fix: clean runtime tests and flatten UiState variants for TS 7 Typecheck the discriminated-union model with TypeScript 7.0.2: flatten set() returns so state is a usable union, keep is() as a this-is type guard (implementation still asserted), and leave exhaustive() uncallable while statuses remain. Remove stale @ts-expect-error on valid match+exhaustive paths and keep union branches in T via const status assertions. --- packages/ui-state/src/index.ts | 8 +++++--- packages/ui-state/src/index.types.test.ts | 3 ++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/ui-state/src/index.ts b/packages/ui-state/src/index.ts index 20adfa2..ea83834 100644 --- a/packages/ui-state/src/index.ts +++ b/packages/ui-state/src/index.ts @@ -19,6 +19,8 @@ type UiStateError = null | { type NonExhaustiveError = UiStateError; type ExhaustiveError = UiStateError; +type Prettify = { [K in keyof T]: T[K] } & {}; + type DataOf< T extends { __status: string }, S extends T["__status"], @@ -74,7 +76,7 @@ export const getUiState = ( >( status: S, data?: SData, - ) => { __status: S } & SData, + ) => Prettify<{ __status: S } & SData>, ) => T, ): UiState => { const state = Object.freeze( @@ -120,11 +122,11 @@ export const getUiState = ( exhaustive: () => handler(state as ExplicitAny) as React.ReactNode, nonExhaustive: () => handler(state as ExplicitAny) as React.ReactNode, - match: (nextStatus, nextHandler) => + match: (nextStatus: ExplicitAny, nextHandler: ExplicitAny) => uiState.match(nextStatus, nextHandler, true, () => handler(uiState.state as ExplicitAny), ), - }; + } as ExplicitAny; } return { diff --git a/packages/ui-state/src/index.types.test.ts b/packages/ui-state/src/index.types.test.ts index 56ff406..77289c0 100644 --- a/packages/ui-state/src/index.types.test.ts +++ b/packages/ui-state/src/index.types.test.ts @@ -1,4 +1,5 @@ import { describe, it, expectTypeOf, expect } from "vitest"; +import type { ReactNode } from "react"; import { getUiState } from "./index.js"; describe("getUiState types", () => { @@ -41,7 +42,7 @@ describe("getUiState types", () => { it("exhaustive() is callable when complete", () => { const done = ui.match("error", () => "e").match("default", () => "d"); - expectTypeOf(done.exhaustive).toEqualTypeOf<() => import("react").ReactNode>(); + expectTypeOf(done.exhaustive).toEqualTypeOf<() => ReactNode>(); expect(done.exhaustive()).toBe("e"); }); From 11f7cc5188ba16129f8dbfb16c0ea6e48efd1cb7 Mon Sep 17 00:00:00 2001 From: prdHugo <48803115+HugoPerard@users.noreply.github.com> Date: Wed, 26 Aug 2026 04:02:54 -0700 Subject: [PATCH 3/9] test: clean stale ts-expect-error for discriminated UiState --- packages/ui-state/src/index.test.ts | 93 +++++++++++++++-------------- 1 file changed, 47 insertions(+), 46 deletions(-) diff --git a/packages/ui-state/src/index.test.ts b/packages/ui-state/src/index.test.ts index 60757f8..5272f1d 100644 --- a/packages/ui-state/src/index.test.ts +++ b/packages/ui-state/src/index.test.ts @@ -108,13 +108,10 @@ describe("getUiState", () => { it("should render the matched status", () => { const ui = getUiState((set) => set("pending")); - // @ts-expect-error this typings should always break as default is not defined - const result = ui - .match("pending", () => "Loading...") - // @ts-expect-error this typings should always break as default is not defined - .match("default", () => "Content") - // @ts-expect-error this typings should always break as default is not defined - .exhaustive(); + const matched = ui.match("pending", () => "Loading..."); + const result = matched.exhaustive(); + // @ts-expect-error extra match after all statuses matched + matched.match("default", () => "Content"); expect(result).toBe("Loading..."); }); @@ -122,13 +119,10 @@ describe("getUiState", () => { it("should render the correct match for default status", () => { const ui = getUiState((set) => set("default", { value: "test" })); - // @ts-expect-error this typings should always break as pending is not defined - const result = ui - .match("default", (data) => `Content: ${data.value}`) - // @ts-expect-error this typings should always break as pending is not defined - .match("pending", () => "Loading...") - // @ts-expect-error this typings should always break as pending is not defined - .exhaustive(); + const matched = ui.match("default", (data) => `Content: ${data.value}`); + const result = matched.exhaustive(); + // @ts-expect-error extra match after all statuses matched + matched.match("pending", () => "Loading..."); expect(result).toBe("Content: test"); }); @@ -136,21 +130,19 @@ describe("getUiState", () => { it("should handle multiple statuses in a single match", () => { const ui = getUiState((set) => set("error")); - // @ts-expect-error this typings should always break as not-found and default are not defined const result = ui - // @ts-expect-error this typings should always break as not-found is not defined + // @ts-expect-error not-found is not defined .match(["error", "not-found"], () => "Error occurred") - // @ts-expect-error this typings should always break as default is not defined - .match("default", () => "Content") - // @ts-expect-error this typings should always break as not-found and default are not defined .exhaustive(); expect(result).toBe("Error occurred"); + + // @ts-expect-error extra match after all statuses matched + ui.match("error", () => "Error occurred").match("default", () => "Content"); }); it("should pass data to handler", () => { - // oxlint-disable-next-line prefer-const - let status = "default"; + const status = "default" as "pending" | "error" | "default"; const ui = getUiState((set) => { if (status === "pending") { return set("pending"); @@ -173,8 +165,12 @@ describe("getUiState", () => { }); it("should chain multiple matches correctly", () => { - // oxlint-disable-next-line prefer-const - let status = "empty"; + const status = "empty" as + | "default" + | "not-found" + | "pending" + | "error" + | "empty"; const ui = getUiState((set) => { if (status === "default") { return set("default"); @@ -199,6 +195,7 @@ describe("getUiState", () => { .match("pending", () => "Loading") .match("error", () => "Error") .match("empty", () => "No items") + .match("not-found", () => "Not found") .match("default", () => "Content") .exhaustive(); @@ -206,8 +203,7 @@ describe("getUiState", () => { }); it("should only execute the first matching handler", () => { - // oxlint-disable-next-line prefer-const - let status = "pending"; + const status = "pending" as "pending" | "default"; const ui = getUiState((set) => { if (status === "default") { return set("default"); @@ -218,23 +214,20 @@ describe("getUiState", () => { let executionCount = 0; - // @ts-expect-error this typings should always break as pending cannot be declared twice - const result = ui - .match("pending", () => { - executionCount++; - return "First match"; - }) - // @ts-expect-error this typings should always break as pending cannot be declared twice - .match("pending", () => { - executionCount++; - return "Second match"; - }) - // @ts-expect-error this typings should always break as pending cannot be declared twice + const matchedOnce = ui.match("pending", () => { + executionCount++; + return "First match"; + }); + // @ts-expect-error pending cannot be declared twice + matchedOnce.match("pending", () => { + executionCount++; + return "Second match"; + }); + const result = matchedOnce .match("default", () => { executionCount++; return "Third match"; }) - // @ts-expect-error this typings should always break as pending cannot be declared twice .exhaustive(); expect(result).toBe("First match"); @@ -252,8 +245,7 @@ describe("getUiState", () => { }); it("should return null when no status matches", () => { - // oxlint-disable-next-line prefer-const - let status = "default"; + const status = "default" as "pending" | "error" | "default"; const ui = getUiState((set) => { if (status === "pending") { return set("pending"); @@ -285,8 +277,7 @@ describe("getUiState", () => { }); it("should handle array of statuses", () => { - // oxlint-disable-next-line prefer-const - let status = "not-found"; + const status = "not-found" as "error" | "not-found"; const ui = getUiState((set) => { if (status === "error") { return set("error"); @@ -316,13 +307,19 @@ describe("getUiState", () => { const errorUi = createUiState("error"); expect(errorUi.is("error")).toBe(true); - // @ts-expect-error TS tests can't infer the attribute here + // @ts-expect-error state is still a union; callback is not narrowed by the param expect(errorUi.state.message).toBe("Something went wrong"); + if (errorUi.is("error")) { + expect(errorUi.state.message).toBe("Something went wrong"); + } const successUi = createUiState("success"); expect(successUi.is("default")).toBe(true); - // @ts-expect-error TS tests can't infer the attribute here + // @ts-expect-error state is still a union; callback is not narrowed by the param expect(successUi.state.data).toBe("Success!"); + if (successUi.is("default")) { + expect(successUi.state.data).toBe("Success!"); + } }); it("should handle empty data object", () => { @@ -371,8 +368,12 @@ describe("getUiState", () => { }); it("should handle match chains with mixed array and single status matches", () => { - // oxlint-disable-next-line prefer-const - let status = "error"; + const status = "error" as + | "default" + | "not-found" + | "pending" + | "empty" + | "error"; const ui = getUiState((set) => { if (status === "default") { return set("default"); From 36fbc924c97f136abd9c27c04f0d790c393016b3 Mon Sep 17 00:00:00 2001 From: prdHugo <48803115+HugoPerard@users.noreply.github.com> Date: Wed, 26 Aug 2026 04:03:01 -0700 Subject: [PATCH 4/9] fix: Prettify set() results and type tests for exhaustive call-site errors --- packages/ui-state/src/index.types.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/ui-state/src/index.types.test.ts b/packages/ui-state/src/index.types.test.ts index 77289c0..584fd9f 100644 --- a/packages/ui-state/src/index.types.test.ts +++ b/packages/ui-state/src/index.types.test.ts @@ -58,4 +58,10 @@ describe("getUiState types", () => { // @ts-expect-error pending is not in the union expect(ui.is("pending")).toBe(false); }); + + it("match() rejects a status already matched", () => { + const rest = ui.match("error", () => null); + // @ts-expect-error error already matched + rest.match("error", () => null); + }); }); From 1aca38017c3cc08abafbc86b11e49d6fe3c8f58e Mon Sep 17 00:00:00 2001 From: prdHugo <48803115+HugoPerard@users.noreply.github.com> Date: Wed, 26 Aug 2026 04:03:47 -0700 Subject: [PATCH 5/9] test: align runtime tests with discriminated UiState --- packages/ui-state/src/index.test.ts | 49 +++++++++++++---------------- 1 file changed, 21 insertions(+), 28 deletions(-) diff --git a/packages/ui-state/src/index.test.ts b/packages/ui-state/src/index.test.ts index 5272f1d..0349e17 100644 --- a/packages/ui-state/src/index.test.ts +++ b/packages/ui-state/src/index.test.ts @@ -108,10 +108,7 @@ describe("getUiState", () => { it("should render the matched status", () => { const ui = getUiState((set) => set("pending")); - const matched = ui.match("pending", () => "Loading..."); - const result = matched.exhaustive(); - // @ts-expect-error extra match after all statuses matched - matched.match("default", () => "Content"); + const result = ui.match("pending", () => "Loading...").exhaustive(); expect(result).toBe("Loading..."); }); @@ -119,26 +116,31 @@ describe("getUiState", () => { it("should render the correct match for default status", () => { const ui = getUiState((set) => set("default", { value: "test" })); - const matched = ui.match("default", (data) => `Content: ${data.value}`); - const result = matched.exhaustive(); - // @ts-expect-error extra match after all statuses matched - matched.match("pending", () => "Loading..."); + const result = ui + .match("default", (data) => `Content: ${data.value}`) + .exhaustive(); expect(result).toBe("Content: test"); }); it("should handle multiple statuses in a single match", () => { - const ui = getUiState((set) => set("error")); + const status = "error" as "error" | "not-found" | "default"; + const ui = getUiState((set) => { + if (status === "not-found") { + return set("not-found"); + } + if (status === "default") { + return set("default"); + } + return set("error"); + }); const result = ui - // @ts-expect-error not-found is not defined .match(["error", "not-found"], () => "Error occurred") + .match("default", () => "Content") .exhaustive(); expect(result).toBe("Error occurred"); - - // @ts-expect-error extra match after all statuses matched - ui.match("error", () => "Error occurred").match("default", () => "Content"); }); it("should pass data to handler", () => { @@ -195,8 +197,8 @@ describe("getUiState", () => { .match("pending", () => "Loading") .match("error", () => "Error") .match("empty", () => "No items") - .match("not-found", () => "Not found") .match("default", () => "Content") + .match("not-found", () => "Not found") .exhaustive(); expect(result).toBe("No items"); @@ -214,16 +216,11 @@ describe("getUiState", () => { let executionCount = 0; - const matchedOnce = ui.match("pending", () => { - executionCount++; - return "First match"; - }); - // @ts-expect-error pending cannot be declared twice - matchedOnce.match("pending", () => { - executionCount++; - return "Second match"; - }); - const result = matchedOnce + const result = ui + .match("pending", () => { + executionCount++; + return "First match"; + }) .match("default", () => { executionCount++; return "Third match"; @@ -307,16 +304,12 @@ describe("getUiState", () => { const errorUi = createUiState("error"); expect(errorUi.is("error")).toBe(true); - // @ts-expect-error state is still a union; callback is not narrowed by the param - expect(errorUi.state.message).toBe("Something went wrong"); if (errorUi.is("error")) { expect(errorUi.state.message).toBe("Something went wrong"); } const successUi = createUiState("success"); expect(successUi.is("default")).toBe(true); - // @ts-expect-error state is still a union; callback is not narrowed by the param - expect(successUi.state.data).toBe("Success!"); if (successUi.is("default")) { expect(successUi.state.data).toBe("Success!"); } From 33039fdbc8ca85e477bcfb97b5168145bfe91de3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20Pe=CC=81rard?= Date: Wed, 26 Aug 2026 16:17:22 +0200 Subject: [PATCH 6/9] test: satisfy oxlint expect-expect and no-conditional-expect Co-authored-by: Cursor --- packages/ui-state/src/index.test.ts | 14 ++++++++------ packages/ui-state/src/index.types.test.ts | 6 ++++-- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/ui-state/src/index.test.ts b/packages/ui-state/src/index.test.ts index 0349e17..ea42a20 100644 --- a/packages/ui-state/src/index.test.ts +++ b/packages/ui-state/src/index.test.ts @@ -304,15 +304,17 @@ describe("getUiState", () => { const errorUi = createUiState("error"); expect(errorUi.is("error")).toBe(true); - if (errorUi.is("error")) { - expect(errorUi.state.message).toBe("Something went wrong"); - } + expect(errorUi.state).toEqual({ + __status: "error", + message: "Something went wrong", + }); const successUi = createUiState("success"); expect(successUi.is("default")).toBe(true); - if (successUi.is("default")) { - expect(successUi.state.data).toBe("Success!"); - } + expect(successUi.state).toEqual({ + __status: "default", + data: "Success!", + }); }); it("should handle empty data object", () => { diff --git a/packages/ui-state/src/index.types.test.ts b/packages/ui-state/src/index.types.test.ts index 584fd9f..1ae94d0 100644 --- a/packages/ui-state/src/index.types.test.ts +++ b/packages/ui-state/src/index.types.test.ts @@ -22,7 +22,7 @@ describe("getUiState types", () => { if (ui.is("error")) { expectTypeOf(ui.state.message).toEqualTypeOf(); // @ts-expect-error title is not on error - expect(ui.state.title).toBeUndefined(); + expectTypeOf(ui.state.title).toEqualTypeOf(); } }); @@ -30,12 +30,13 @@ describe("getUiState types", () => { if (ui.state.__status === "default") { expectTypeOf(ui.state.title).toEqualTypeOf(); // @ts-expect-error message is not on default - expect(ui.state.message).toBeUndefined(); + expectTypeOf(ui.state.message).toEqualTypeOf(); } }); it("exhaustive() is not callable while statuses remain", () => { const partial = ui.match("error", () => null); + expectTypeOf(partial.exhaustive).not.toEqualTypeOf<() => ReactNode>(); // @ts-expect-error default is missing partial.exhaustive(); }); @@ -61,6 +62,7 @@ describe("getUiState types", () => { it("match() rejects a status already matched", () => { const rest = ui.match("error", () => null); + expectTypeOf(rest.match).toBeCallableWith("default", () => null); // @ts-expect-error error already matched rest.match("error", () => null); }); From 3eb703b379394f9eca8fdeba2e384a25212677d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20Pe=CC=81rard?= Date: Wed, 26 Aug 2026 17:43:20 +0200 Subject: [PATCH 7/9] fix: distribute set() over status unions set(status) with a union produced a single { __status: A | B } object, so exhaustive() still required both variants after matching them. Co-authored-by: Cursor --- packages/ui-state/src/index.test.ts | 12 ++++++++++ packages/ui-state/src/index.ts | 28 ++++++++++++++--------- packages/ui-state/src/index.types.test.ts | 17 ++++++++++++++ 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/packages/ui-state/src/index.test.ts b/packages/ui-state/src/index.test.ts index ea42a20..650f90b 100644 --- a/packages/ui-state/src/index.test.ts +++ b/packages/ui-state/src/index.test.ts @@ -143,6 +143,18 @@ describe("getUiState", () => { expect(result).toBe("Error occurred"); }); + it("should match and exhaustive from set() with a status union", () => { + const status = "error" as "pending" | "error" | "success"; + + const result = getUiState((set) => set(status)) + .match("pending", () => "Loading") + .match("error", () => "Error") + .match("success", () => "OK") + .exhaustive(); + + expect(result).toBe("Error"); + }); + it("should pass data to handler", () => { const status = "default" as "pending" | "error" | "default"; const ui = getUiState((set) => { diff --git a/packages/ui-state/src/index.ts b/packages/ui-state/src/index.ts index ea83834..a2c7da5 100644 --- a/packages/ui-state/src/index.ts +++ b/packages/ui-state/src/index.ts @@ -31,6 +31,20 @@ type ExcludeStatus< S extends string, > = T extends { __status: S } ? never : T; +type SetResult = S extends unknown + ? Prettify< + { __status: S } & (SData extends Record ? SData : unknown) + > + : never; + +type GetUiStateSet = < + S extends AvailableStatus, + SData extends Record | undefined = undefined, +>( + status: S, + data?: SData, +) => SetResult; + type MatchResult = { nonExhaustive: () => React.ReactNode; } & ([Rest] extends [never] @@ -69,23 +83,15 @@ type UiState = { }; export const getUiState = ( - getState: ( - set: < - S extends AvailableStatus, - SData extends Record = Record, - >( - status: S, - data?: SData, - ) => Prettify<{ __status: S } & SData>, - ) => T, + getState: (set: GetUiStateSet) => T, ): UiState => { const state = Object.freeze( - getState((status, data = {} as ExplicitAny) => { + getState(((status, data = {} as ExplicitAny) => { return { __status: status, ...data, }; - }), + }) as GetUiStateSet), ); const isMatching = (status: T["__status"]): boolean => diff --git a/packages/ui-state/src/index.types.test.ts b/packages/ui-state/src/index.types.test.ts index 1ae94d0..2b00040 100644 --- a/packages/ui-state/src/index.types.test.ts +++ b/packages/ui-state/src/index.types.test.ts @@ -67,3 +67,20 @@ describe("getUiState types", () => { rest.match("error", () => null); }); }); + +describe("getUiState from set() with a status union", () => { + const status = "test" as "test" | "default"; + const ui = getUiState((set) => set(status)); + + it("splits set(status) into a discriminated state", () => { + expectTypeOf(ui.state).toEqualTypeOf< + { __status: "test" } | { __status: "default" } + >(); + }); + + it("exhaustive() is callable when the union is covered", () => { + const done = ui.match("test", () => "t").match("default", () => "d"); + expectTypeOf(done.exhaustive).toEqualTypeOf<() => ReactNode>(); + expect(done.exhaustive()).toBe("t"); + }); +}); From 27e6eb5fd1adc701a98b3f99b7daa167eaf17f59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20Pe=CC=81rard?= Date: Wed, 26 Aug 2026 17:54:02 +0200 Subject: [PATCH 8/9] fix: reject exhaustive() before match and empty-status extra keys exhaustive() was a callable stub on UiState, unlike incomplete match chains. set() without data now stays { __status } so pending cannot read another variant's payload. Co-authored-by: Cursor --- packages/ui-state/src/index.ts | 14 ++++----- packages/ui-state/src/index.types.test.ts | 37 +++++++++++++++++++++++ 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/packages/ui-state/src/index.ts b/packages/ui-state/src/index.ts index a2c7da5..4677409 100644 --- a/packages/ui-state/src/index.ts +++ b/packages/ui-state/src/index.ts @@ -32,9 +32,9 @@ type ExcludeStatus< > = T extends { __status: S } ? never : T; type SetResult = S extends unknown - ? Prettify< - { __status: S } & (SData extends Record ? SData : unknown) - > + ? [SData] extends [undefined] + ? { __status: S } + : Prettify<{ __status: S } & SData> : never; type GetUiStateSet = < @@ -68,8 +68,8 @@ type UiState = { status: S | Array, handler: (data: DataOf) => R, ) => R | null; - exhaustive: () => ExhaustiveError<`\`exhaustive()\` should be use after \`match\``>; - nonExhaustive: () => NonExhaustiveError<`\`nonExhaustive()\` should be use after \`match\``>; + exhaustive: ExhaustiveError<`\`exhaustive()\` should be use after \`match\``>; + nonExhaustive: NonExhaustiveError<`\`nonExhaustive()\` should be use after \`match\``>; match: ( status: S | Array, handler: ( @@ -115,8 +115,8 @@ export const getUiState = ( } return null; }, - nonExhaustive: () => null, - exhaustive: () => null, + nonExhaustive: (() => null) as ExplicitAny, + exhaustive: (() => null) as ExplicitAny, match: (status, handler, __matched = false, render = () => null) => { if ( !__matched && diff --git a/packages/ui-state/src/index.types.test.ts b/packages/ui-state/src/index.types.test.ts index 2b00040..99660e6 100644 --- a/packages/ui-state/src/index.types.test.ts +++ b/packages/ui-state/src/index.types.test.ts @@ -41,6 +41,12 @@ describe("getUiState types", () => { partial.exhaustive(); }); + it("rejects exhaustive() before any match", () => { + expectTypeOf(ui.exhaustive).not.toEqualTypeOf<() => ReactNode>(); + // @ts-expect-error exhaustive requires a complete match chain + ui.exhaustive(); + }); + it("exhaustive() is callable when complete", () => { const done = ui.match("error", () => "e").match("default", () => "d"); expectTypeOf(done.exhaustive).toEqualTypeOf<() => ReactNode>(); @@ -84,3 +90,34 @@ describe("getUiState from set() with a status union", () => { expect(done.exhaustive()).toBe("t"); }); }); + +describe("getUiState empty payload vs data payload", () => { + const cond = true as boolean; + const book = { title: "Dune" }; + const ui = getUiState((set) => { + if (cond) { + return set("pending"); + } + return set("default", { book }); + }); + + it("pending state has no data keys", () => { + if (ui.is("pending")) { + type Pending = typeof ui.state; + type HasBook = "book" extends keyof Pending ? true : false; + expectTypeOf().toEqualTypeOf(); + expectTypeOf(ui.state).toEqualTypeOf<{ __status: "pending" }>(); + // @ts-expect-error book is not on pending + expectTypeOf(ui.state.book).toEqualTypeOf(book); + } + }); + + it("shared match does not expose keys missing from a variant", () => { + ui.match(["pending", "default"], (data) => { + type HasBook = "book" extends keyof typeof data ? true : false; + expectTypeOf().toEqualTypeOf(); + // @ts-expect-error book is not on pending + return data.book; + }); + }); +}); From 32a13bd56cbf2a0cdfd1ddc49ab7d323e45185c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20Pe=CC=81rard?= Date: Wed, 26 Aug 2026 17:55:39 +0200 Subject: [PATCH 9/9] revert: allow exhaustive() before match Keep empty-status payload checking; calling exhaustive() on UiState without a match chain is allowed again. Co-authored-by: Cursor --- packages/ui-state/src/index.ts | 8 ++++---- packages/ui-state/src/index.types.test.ts | 6 ------ 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/ui-state/src/index.ts b/packages/ui-state/src/index.ts index 4677409..aa07445 100644 --- a/packages/ui-state/src/index.ts +++ b/packages/ui-state/src/index.ts @@ -68,8 +68,8 @@ type UiState = { status: S | Array, handler: (data: DataOf) => R, ) => R | null; - exhaustive: ExhaustiveError<`\`exhaustive()\` should be use after \`match\``>; - nonExhaustive: NonExhaustiveError<`\`nonExhaustive()\` should be use after \`match\``>; + exhaustive: () => ExhaustiveError<`\`exhaustive()\` should be use after \`match\``>; + nonExhaustive: () => NonExhaustiveError<`\`nonExhaustive()\` should be use after \`match\``>; match: ( status: S | Array, handler: ( @@ -115,8 +115,8 @@ export const getUiState = ( } return null; }, - nonExhaustive: (() => null) as ExplicitAny, - exhaustive: (() => null) as ExplicitAny, + nonExhaustive: () => null, + exhaustive: () => null, match: (status, handler, __matched = false, render = () => null) => { if ( !__matched && diff --git a/packages/ui-state/src/index.types.test.ts b/packages/ui-state/src/index.types.test.ts index 99660e6..d616575 100644 --- a/packages/ui-state/src/index.types.test.ts +++ b/packages/ui-state/src/index.types.test.ts @@ -41,12 +41,6 @@ describe("getUiState types", () => { partial.exhaustive(); }); - it("rejects exhaustive() before any match", () => { - expectTypeOf(ui.exhaustive).not.toEqualTypeOf<() => ReactNode>(); - // @ts-expect-error exhaustive requires a complete match chain - ui.exhaustive(); - }); - it("exhaustive() is callable when complete", () => { const done = ui.match("error", () => "e").match("default", () => "d"); expectTypeOf(done.exhaustive).toEqualTypeOf<() => ReactNode>();