Naalya Handbook
The Data Layer

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:

src/services/api/api.error.ts
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:

src/services/api/api.error.ts
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:

src/queries/base.query.ts
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:

src/queries/base.query.ts
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

On this page