The Result Pattern
API calls return a {data, error} Result instead of throwing — and the query layer unwraps it so React Query still sees errors.
Most HTTP clients throw when a request fails. You call await api.get(...), the server returns a 404, and an exception comes flying out of the middle of your function — so every call site needs a try/catch or it'll crash the page. The Education Hub takes the opposite stance at the transport boundary: the client never throws. A failed request is just another return value.
That single decision is the heart of the data layer. Understanding it — and the small twist that follows — is the difference between code that fights the network and code that handles it calmly.
Never throw
When you call any method on api, you don't get back your data. You get back a Result — an object that holds either the data or an error, never both. The transport layer guarantees this by wrapping every Axios call in safeRequest, which catches the throw and hands it back to you as a value:
export async function safeRequest<T>(
promise: Promise<AxiosResponse<T>>,
): Promise<Result<T>> {
try {
const response = await promise
return { data: response.data, error: null }
} catch (err) {
return { data: null, error: createApiError(err) }
}
}That's the whole trick. There is no path through safeRequest that throws — a successful response and a network meltdown both leave through return. Every typed method on the client (api.get, api.post, api.put, and the semantic api.op.* operations) routes through this wrapper, so they all share the same promise: Promise<Result<T>>, never a surprise exception.
No surprise control flow
Because failures are return values, you can't accidentally ignore them. The type system forces you to look inside the Result before you can touch data — there's no way to read the success value without first acknowledging that an error might be sitting there instead.
The Result shape
A Result<T> is a discriminated union — two possible shapes, distinguished by which field is null:
export type Result<T> =
| { data: T; error: null }
| { data: null; error: ApiError }This is what makes the pattern ergonomic in TypeScript. Once you check if (error) and handle it, the compiler narrows data to T on the other side — no casting, no non-null assertions. The two isSuccess / isError type guards do the same job when you'd rather not destructure.
The error half is never a raw Axios blob. createApiError normalizes it into an ApiError carrying a human-readable message, a stable code (UNAUTHORIZED, VALIDATION_ERROR, NETWORK_ERROR...), the HTTP status, and a redacted snapshot of the request for debugging.
One message out of many backends
Different backends bury their error text in different places — message, error, errors[], FastAPI's detail, ASP.NET's title. normalizeErrorMessage knows all of those shapes and digs out the one string worth showing a user, so the rest of the app never has to care which framework answered.
Unwrapping at the query layer
Here's the twist. If the client never throws, how does React Query know a request failed? Its whole API — isError, error, retries, error boundaries — is built around promises that reject. A Result that quietly resolves with { data: null } would look like success.
So the second layer turns the Result back into a throw. Every query class extends BaseQuery, which has one job at its core — exec:
protected async exec<T>(promise: Promise<Result<T>>): Promise<T> {
const { data, error } = await promise
if (error) throw new QueryError(error)
return data
}Read it as a translator. It takes the Promise<Result<T>> the client produced, unwraps it, and re-throws on error — but as a clean Promise<T>. Your query methods wrap their api calls in this.exec(...), so what flows out to React Query is exactly what it expects: data on success, a rejection on failure.
The error it throws isn't a generic Error. It's a QueryError that carries the ApiError's code and status forward, so a global handler can react intelligently — bounce a 401 to login, show a toast on 500 — without re-parsing anything:
export class QueryError extends Error {
readonly code: string
readonly status: number | null
constructor(apiError: ApiError) {
super(apiError.message)
this.name = 'QueryError'
this.code = apiError.code
this.status = apiError.status
}
}Why two layers
It looks redundant at first — catch the throw, then throw again. It isn't. The two layers serve two different audiences.
The transport layer answers to structure. At the network boundary you want no surprises: errors are data, status codes are preserved, nothing escapes uncaught. A Result is the honest representation of "this either worked or it didn't."
The query layer answers to ergonomics. React Query, error boundaries, and useQuery's isError flag all speak the language of thrown rejections. Forcing every component to manually unwrap a Result would be noise — so exec speaks their language for them.
Components never see a Result
This is the payoff: by the time data reaches a component, it has already been unwrapped. You read data, isLoading, and error off useQuery like normal. If you ever find yourself destructuring { data, error } from a Result inside a component, you've reached past the query layer — wrap the call in a query class instead.
So the rule of thumb is simple. Inside a query class, you handle Results — call api, pass the promise through this.exec. Everywhere else, you handle React Query — clean data, or a thrown QueryError. The Result lives and dies in the data layer, exactly where it belongs.
Where to go next
The Generated Client
Where api.get and api.op come from, and why they're fully typed from the OpenAPI spec.
A Feature End to End
Follow one request from query class through exec to the component that renders it.
Keys & Caching
How query keys are built and how cached data stays fresh.
Sessions & Tokens
How a thrown 401 turns into a token refresh instead of a logout.