Error Handling and Result Pattern Quickstart
April 4, 2026 · View on GitHub
中文 | English
Error Handling and Result Pattern Quickstart
This document is intended to unify the error handling logic within the project, helping contributors quickly decide whether to "return a Result or throw directly."
One-Line Rules
Resultis only for handling business errors (expected, recoverable, requiring UI prompts or branch logic).- Request-level errors (authentication, permissions, rate limiting, Turnstile verification) are thrown directly by middlewares (this refers to TanStack Start / ServerFn middlewares;
Hono /apiis an independent HTTP channel and doesn't follow thisResultconvention). - Services without business errors should return
Tdirectly, do not wrap it inok(...). - Let TypeScript infer return types by default, explicitly annotate "lock types" only when necessary at public boundaries.
Layered Responsibilities
middleware
- Handles request-level issues:
UNAUTHENTICATED,PERMISSION_DENIED,RATE_LIMITED,TURNSTILE_FAILED. - Throws serializable errors using
createXxxError(located insrc/lib/errors/request-errors.ts).
service
- Handles business rules and business errors, e.g.,
POST_NOT_FOUND,MEDIA_IN_USE,TAG_NOT_FOUND. - Returns
Result<T, { reason: ... }>when there is a business error. - Returns pure data
Twhen there is no business error.
api (server function)
- Keep it as a thin forwarding layer: auth and rate limiting are handled by middlewaares, business logic is passed to the service.
- Do not re-wrap
ok(...)at the API layer (unless the API itself indeed has independent business error branches).
client (TanStack Query)
query/mutationshould by default not write customonError, request-level errors are handled uniformly by the globalonError:src/lib/errors/error-handler.ts.- Business errors are handled uniformly within
onSuccessunder theresult.error.reasonbranch.
When to use Result
Typical scenarios for using Result:
- Resource not found:
POST_NOT_FOUND,COMMENT_NOT_FOUND. - State conflicts:
MEDIA_IN_USE,TAG_IN_USE. - Business preconditions not met:
EMAIL_DISABLED,INVALID_PROGRESS_DATA.
Scenarios where Result is NOT used:
- Pure queries/writes where there are no business failure branches, failures can only be system exceptions.
- Request-level failures (auth, perms, rate limits) which are already handled by middlewares.
Full-Chain Example
1) Middleware: Throwing request-level errors
// src/lib/middlewares.ts
if (!context.session) {
throw createAuthError();
}
if (context.session.user.role !== "admin") {
throw createPermissionError();
}
2) Service: Returning only business errors
// src/features/tags/tags.service.ts
import { err, ok } from "@/lib/errors";
export async function deleteTag(id: string) {
const found = await repo.findById(id);
if (!found) return err({ reason: "TAG_NOT_FOUND" });
await repo.remove(id);
return ok(undefined);
}
// No business errors: return T directly
export async function listTags() {
return repo.listAll();
}
If you wish to fix type boundaries for public consumption, then explicitly annotate Promise<Result<...>>.
3) API: Thin Forwarding
// src/features/tags/api/tags.admin.api.ts
export const deleteTagFn = createServerFn({ method: "POST" })
.middleware([adminMiddleware]) // Request-level errors thrown by middleware
.validator((data: { id: string }) => data)
.handler(async ({ data }) => {
return TagService.deleteTag(data.id); // Business errors passed forward as Result
});
4) Query: Returning data directly when no business errors exist
// src/features/tags/queries/index.ts
export function tagsQuery() {
return queryOptions({
queryKey: TAGS_KEYS.all,
queryFn: async () => {
return getTagsFn(); // getTagsFn returns Tag[], not Result
},
});
}
5) Mutation: Handling business errors centrally in the hook
// src/features/tags/hooks/use-tags.ts
export function useDeleteTag() {
return useMutation({
mutationFn: (id: string) => deleteTagFn({ data: { id } }),
onSuccess: (result) => {
if (result.error) {
switch (result.error.reason) {
case "TAG_NOT_FOUND":
toast.error("Tag not found or already deleted");
return;
default:
result.error.reason satisfies never;
return;
}
}
toast.success("Tag deleted");
},
});
}
6) Global Request Error Handling (TanStack Query)
// src/integrations/tanstack-query/root-provider.tsx
const queryClient = new QueryClient({
queryCache: new QueryCache({ onError: handleServerError }),
mutationCache: new MutationCache({ onError: handleServerError }),
});
// src/lib/errors/error-handler.ts
export function handleServerError(error: unknown) {
const parsed = parseRequestError(error);
const { code } = parsed;
switch (code) {
case "UNAUTHENTICATED":
window.location.href = "/login";
return;
case "PERMISSION_DENIED":
toast.error("Insufficient permissions");
return;
case "RATE_LIMITED":
toast.warning("Too many requests");
return;
case "TURNSTILE_FAILED":
toast.error(parsed.message);
return;
case "UNKNOWN":
toast.error("Request failed");
return;
default:
code satisfies never;
}
}
7) handleServerError must perform exhaustive checks
- In the
switch, explicitly list each request errorcode. - The
defaultbranch should only retaincode satisfies never, do not add a fallback toast. - After adding a new member to the
request-errorsunion, TypeScript must immediately throw an error if this is not updated.
What to change when adding a new request error
- Add the new
codeand fields to the zod union insrc/lib/errors/request-errors.ts. - Add the corresponding
createXxxErrorconstructor function. - Add a frontend handling branch for that
codeinsrc/lib/errors/error-handler.ts. - Add tests: at minimum, cover serialization/deserialization and frontend behavior.
PR Self-Checklist
- Did you mistakenly write a request-level error as a
Result? - Did you mistakenly
throwa business error? - Are services without business errors still returning
ok(data)? - Is the Query swallowing business errors (e.g., directly returning an empty array), leaving the user unaware?