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.test.ts b/packages/ui-state/src/index.test.ts index 60757f8..650f90b 100644 --- a/packages/ui-state/src/index.test.ts +++ b/packages/ui-state/src/index.test.ts @@ -108,13 +108,7 @@ 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 result = ui.match("pending", () => "Loading...").exhaustive(); expect(result).toBe("Loading..."); }); @@ -122,35 +116,47 @@ 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(); 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"); + }); - // @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 .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"); }); + 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", () => { - // 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 +179,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"); @@ -200,14 +210,14 @@ describe("getUiState", () => { .match("error", () => "Error") .match("empty", () => "No items") .match("default", () => "Content") + .match("not-found", () => "Not found") .exhaustive(); expect(result).toBe("No items"); }); 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 +228,15 @@ 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 .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 +254,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 +286,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 +316,17 @@ describe("getUiState", () => { const errorUi = createUiState("error"); expect(errorUi.is("error")).toBe(true); - // @ts-expect-error TS tests can't infer the attribute here - 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); - // @ts-expect-error TS tests can't infer the attribute here - expect(successUi.state.data).toBe("Success!"); + expect(successUi.state).toEqual({ + __status: "default", + data: "Success!", + }); }); it("should handle empty data object", () => { @@ -371,8 +375,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"); diff --git a/packages/ui-state/src/index.ts b/packages/ui-state/src/index.ts index b4ddb16..aa07445 100644 --- a/packages/ui-state/src/index.ts +++ b/packages/ui-state/src/index.ts @@ -19,92 +19,97 @@ 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 Prettify = { [K in keyof T]: T[K] } & {}; + +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 SetResult = S extends unknown + ? [SData] extends [undefined] + ? { __status: S } + : Prettify<{ __status: S } & SData> + : never; + +type GetUiStateSet = < + S extends AvailableStatus, + SData extends Record | undefined = undefined, +>( + status: S, + data?: SData, +) => SetResult; + +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, ->( - getState: ( - set: >( - status: S, - data?: SData, - ) => { __status: S } & SData, - ) => { __status: Status } & Data, -): UiState => { +export const getUiState = ( + 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: 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,23 +122,24 @@ 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: ExplicitAny, nextHandler: ExplicitAny) => + uiState.match(nextStatus, nextHandler, true, () => handler(uiState.state as ExplicitAny), ), - }; + } as ExplicitAny; } 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..d616575 --- /dev/null +++ b/packages/ui-state/src/index.types.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expectTypeOf, expect } from "vitest"; +import type { ReactNode } from "react"; +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 + expectTypeOf(ui.state.title).toEqualTypeOf(); + } + }); + + it("__status check narrows state", () => { + if (ui.state.__status === "default") { + expectTypeOf(ui.state.title).toEqualTypeOf(); + // @ts-expect-error message is not on default + 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(); + }); + + it("exhaustive() is callable when complete", () => { + const done = ui.match("error", () => "e").match("default", () => "d"); + expectTypeOf(done.exhaustive).toEqualTypeOf<() => 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); + }); + + 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); + }); +}); + +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"); + }); +}); + +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; + }); + }); +});