feat: Implement typed error contracts in generic hooks
Introduce discriminated FetchError union type to replace weak string error handling in API calls and hooks. Enables actionable error diagnostics. Changes: - Create types/api.ts with FetchError discriminated union (api_error, network_error, abort_error) - Export type guards: isAuthError, isAbortError, isNetworkError, isApiError - Update useListData and usePolledData to expose typed FetchError instead of string - Add getErrorMessage() helper to extract displayable messages from FetchError - Add createStringErrorAdapter() for backward compatibility with string error state - Update handleFetchError() to work with both FetchError and string setters - Update all consumer hooks to expose typed errors - Update components to use getErrorMessage() when displaying errors - Update tests to mock FetchError instead of strings - Add comprehensive typed error model documentation to Web-Development.md This enables better error handling patterns: - Check error.type to distinguish between API, network, and abort errors - Extract status codes for specific handling (401/403 auth, 50x server errors) - Maintain backward compatibility with existing string-based error states All TypeScript compilation passes with no errors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -990,6 +990,104 @@ export function useSaveData() {
|
||||
- Log errors to the console (or a future logging service) with sufficient context for debugging.
|
||||
- Always handle the **loading**, **error**, and **empty** states for every data-driven component.
|
||||
|
||||
### Typed Error Model
|
||||
|
||||
All API errors are normalized into a discriminated `FetchError` union type. This replaces generic error strings with **actionable, typed error information** that enables better diagnostics and UX.
|
||||
|
||||
**The three error types:**
|
||||
|
||||
```ts
|
||||
// From src/types/api.ts
|
||||
|
||||
// Server returned HTTP error (non-2xx status)
|
||||
interface ApiErrorPayload {
|
||||
type: "api_error";
|
||||
status: number; // 401, 403, 500, etc.
|
||||
body: string; // Raw response body
|
||||
message: string; // Formatted for display
|
||||
}
|
||||
|
||||
// Network failure, DNS lookup failure, JSON parse error, etc.
|
||||
interface NetworkErrorPayload {
|
||||
type: "network_error";
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Request was cancelled (component unmount, user abort, etc.)
|
||||
interface AbortErrorPayload {
|
||||
type: "abort_error";
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Union of all three
|
||||
type FetchError = ApiErrorPayload | NetworkErrorPayload | AbortErrorPayload;
|
||||
```
|
||||
|
||||
**Usage in hooks:**
|
||||
|
||||
```ts
|
||||
import { useListData } from "./useListData";
|
||||
import type { FetchError } from "../types/api";
|
||||
import { isAuthError, isApiError } from "../types/api";
|
||||
|
||||
export function useBans(): UseBansResult {
|
||||
const { items, loading, error, refresh } = useListData<BanListResponse, Ban>({
|
||||
fetcher,
|
||||
selector,
|
||||
errorMessage: "Failed to load bans",
|
||||
});
|
||||
|
||||
// error is now FetchError | null, not string | null
|
||||
// Use the type discriminator to handle different cases:
|
||||
|
||||
if (error?.type === "api_error") {
|
||||
if (error.status === 401 || error.status === 403) {
|
||||
// Already handled globally by AuthProvider, but you can check specifically
|
||||
} else if (error.status >= 500) {
|
||||
// Server error — suggest retry or contact support
|
||||
}
|
||||
} else if (error?.type === "network_error") {
|
||||
// Network connectivity issue — show offline-friendly message
|
||||
} else if (error?.type === "abort_error") {
|
||||
// Request was cancelled — typically silent (component unmounted)
|
||||
}
|
||||
|
||||
return { items, loading, error, refresh };
|
||||
}
|
||||
```
|
||||
|
||||
**Extracting displayable messages:**
|
||||
|
||||
```ts
|
||||
import { getErrorMessage } from "../utils/fetchError";
|
||||
|
||||
// Convert any FetchError to a user-friendly string:
|
||||
if (error) {
|
||||
const message = getErrorMessage(error);
|
||||
// Abort and auth errors return empty string (silently handled)
|
||||
// Other errors return error.message
|
||||
if (message) {
|
||||
showNotification(message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Backward compatibility with string-based error state:**
|
||||
|
||||
If a component still uses `useState<string | null>` for errors, use the adapter:
|
||||
|
||||
```ts
|
||||
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
fetch()
|
||||
.catch((err: unknown) => {
|
||||
// Adapt the FetchError to string setter
|
||||
handleFetchError(err, createStringErrorAdapter(setErrorMessage), "Failed");
|
||||
});
|
||||
```
|
||||
|
||||
### Error Boundaries — Granular Fallback Strategy
|
||||
|
||||
React error boundaries catch render-time exceptions and allow graceful fallback UI instead of a full white screen crash. BanGUI implements a **three-level error boundary strategy** to balance resilience with UX clarity:
|
||||
|
||||
Reference in New Issue
Block a user