Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/discriminated-ui-state.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 1 addition & 2 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/**"]
}
88 changes: 48 additions & 40 deletions packages/ui-state/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,49 +108,55 @@ 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...");
});

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");
Expand All @@ -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");
Expand All @@ -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");
Expand All @@ -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");
Expand All @@ -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");
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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");
Expand Down
134 changes: 70 additions & 64 deletions packages/ui-state/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,92 +19,97 @@ type UiStateError<Message extends string> = null | {
type NonExhaustiveError<Message extends string = ""> = UiStateError<Message>;
type ExhaustiveError<Message extends string = ""> = UiStateError<Message>;

type UiState<
Status extends AvailableStatus,
Data extends Record<string, unknown>,
> = {
is: <S extends Status>(status: S) => boolean;
state: {
__status: Status;
} & Data;
when: <
S extends Status,
R = React.ReactNode,
SData = Omit<
Extract<UiState<Status, Data>["state"], { __status: S }>,
"__status"
>,
>(
type Prettify<T> = { [K in keyof T]: T[K] } & {};

type DataOf<
T extends { __status: string },
S extends T["__status"],
> = T extends { __status: S } ? Omit<T, "__status"> : never;

type ExcludeStatus<
T extends { __status: string },
S extends string,
> = T extends { __status: S } ? never : T;

type SetResult<S extends string, SData> = S extends unknown
? [SData] extends [undefined]
? { __status: S }
: Prettify<{ __status: S } & SData>
: never;

type GetUiStateSet = <
S extends AvailableStatus,
SData extends Record<string, unknown> | undefined = undefined,
>(
status: S,
data?: SData,
) => SetResult<S, SData>;

type MatchResult<Rest extends { __status: string }> = {
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<Rest>["match"];
});

type UiState<T extends { __status: AvailableStatus }> = {
is: <S extends T["__status"]>(
status: S,
) => this is UiState<Extract<T, { __status: S }>>;
state: T;
when: <S extends T["__status"], R = React.ReactNode>(
status: S | Array<S>,
handler: (data: SData) => R,
handler: (data: DataOf<T, S>) => 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<UiState<Status, Data>["state"], { __status: S }>,
"__status"
>,
>(
match: <S extends T["__status"]>(
status: S | Array<S>,
handler: (
data: SData,
data: DataOf<T, S>,
) => React.ReactNode | ((...args: ExplicitAny[]) => React.ReactNode),
__matched?: boolean,
render?: () =>
| React.ReactNode
| ((...args: ExplicitAny[]) => React.ReactNode),
) => {
nonExhaustive: () => React.ReactNode;
} & (Exclude<Status, S> extends never
? {
exhaustive: () => React.ReactNode;
match: () => ExhaustiveError<"All status are already matched">;
}
: {
exhaustive: () => ExhaustiveError<`${Exclude<Status, S>} is missing to use \`exhaustive()\``>;
match: UiState<Exclude<Status, S>, Data>["match"];
});
) => MatchResult<ExcludeStatus<T, S>>;
};

export const getUiState = <
Status extends AvailableStatus,
Data extends Record<string, unknown>,
>(
getState: (
set: <S extends AvailableStatus, SData extends Record<string, unknown>>(
status: S,
data?: SData,
) => { __status: S } & SData,
) => { __status: Status } & Data,
): UiState<Status, Data> => {
export const getUiState = <T extends { __status: AvailableStatus }>(
getState: (set: GetUiStateSet) => T,
): UiState<T> => {
const state = Object.freeze(
getState((status, data = {} as ExplicitAny) => {
getState(((status, data = {} as ExplicitAny) => {
return {
__status: status,
...data,
};
}),
}) as GetUiStateSet),
);

const isMatching = <S extends Status>(status: Status): status is S =>
const isMatching = (status: T["__status"]): boolean =>
status === state.__status;

const isMatchingArray = <S extends Status>(
status: Array<Status>,
): status is Array<S> => status.includes(state.__status);
const isMatchingArray = (status: Array<T["__status"]>): boolean =>
status.includes(state.__status);

const uiState: UiState<Status, Data> = {
const uiState: UiState<T> = {
state,
is: (status) => {
is: ((status: T["__status"]) => {
return state.__status === status;
},
}) as UiState<T>["is"],
when: (status, handler) => {
if (
typeof status === "string"
? isMatching(status)
: isMatchingArray(status as Array<Status>)
: isMatchingArray(status)
) {
return handler(state as ExplicitAny);
}
Expand All @@ -117,23 +122,24 @@ export const getUiState = <
!__matched &&
(typeof status === "string"
? isMatching(status)
: isMatchingArray(status as Array<Status>))
: 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;
},
};
Expand Down
Loading
Loading