ehsan.blog
~/blog/api-client-part-3-methods-response-envelope — zsh
cat api-client-part-3-methods-response-envelope.md

Composing calls and the response envelope you're throwing away

·4 min read

Part two covered ctx.request(...) as a black box that returns data. This post opens that box — what’s actually in the response, and what happens when one method needs to hit two or three endpoints and combine them into a single, sane return value.

The envelope you’ve been ignoring

Every ctx.request(...) call resolves to an ApiResponse<T>, not just your data:

ts
interface ApiResponse<T> {
  data: T
  status: number
  statusText?: string
  headers: Record<string, string>
  fromCache?: boolean
}

Problem: most of the time you only want data, so writing (await ctx.request(...)).data everywhere feels like noise. But sometimes a caller genuinely needs the status code — checking for a 202 Accepted vs a 200 OK, or reading a Location header after a create call.

Solution: return whichever shape the caller actually needs, method by method. You’re not locked into one convention for the whole client.

ts
// Clean API — caller just wants the data
get: async (ctx, id: string) =>
  (await ctx.request({ method: 'GET', path: '/invoices/{id}', pathParams: { id } })).data,

// Caller needs status/headers too — return the whole envelope
getRaw: async (ctx, id: string) =>
  ctx.request<Invoice>({ method: 'GET', path: '/invoices/{id}', pathParams: { id } }),
ts
// using getRaw
const { data, status, fromCache } = await api.invoices.getRaw('1')
if (status === 202) showPendingBanner()

fromCache is the field I reach for most — it’s how you show a “refreshing…” indicator on a stale-while-revalidate response without wiring up separate loading state.

Composed calls — combining endpoints in one method

Problem: your UI needs an invoice and its line items, but your backend exposes them as two separate endpoints. Doing this in the component means two useQuery hooks, two loading states, and a component that has to know too much about your API shape.

Solution: write one method that makes both calls and returns a combined shape. ctx.client gives you access to other modules from inside a method, so composition isn’t limited to requests within the same module.

ts
defineModule({
  methods: {
    getWithLines: async (ctx, id: string) => {
      const invoice = (await ctx.request({
        method: 'GET', path: '/invoices/{id}', pathParams: { id },
      })).data

      const lines = (await ctx.request({
        method: 'GET', path: '/invoices/{id}/lines', pathParams: { id },
      })).data

      return { invoice, lines }
    },
  },
})
plaintext
Component


api.invoices.getWithLines(id)

   ├──▶ GET /invoices/{id}      ──▶ invoice
   └──▶ GET /invoices/{id}/lines ──▶ lines


{ invoice, lines }   ← one typed return value

Both requests still go through the full pipeline independently — each gets its own cache entry, its own dedup key, its own retry behavior. Composition happens at the method level, not by bypassing the pipeline.

Real talk: don’t over-compose

I’ve made the mistake of building one giant getEverything method that fires six requests because it seemed convenient at the time. Don’t. If two parts of your UI only need the invoice, and a third needs invoice-plus-lines, keep get and getWithLines as separate methods. Composition is for genuine one-screen, one-shot data needs — not a way to avoid writing a second method.

Typing responses so callers don’t have to guess

ts
type Invoice = { id: string; amount: number; status: 'draft' | 'paid' }

list: async (ctx): Promise<Invoice[]> =>
  (await ctx.request<Invoice[]>({ method: 'GET', path: '/invoices' })).data,

The generic on ctx.request<T> is what flows through to data. Annotate it, and every caller of api.invoices.list() gets full autocomplete on the result — no any, no guessing what fields exist. If you’re using the codegen CLI (covered later in this series), you import these types instead of hand-writing them, but the mechanism is the same either way.

Module-level config and extension

A module isn’t just a bag of methods — it can carry its own config that overrides the global client settings, which matters the moment one part of your API lives somewhere different from the rest.

Problem: your main API is at api.example.com, but payroll data lives on a separate internal host with its own auth.

Solution:

ts
defineModule({
  config: {
    baseURL: 'https://payroll.internal',
    timeout: 30_000,
    auth: {
      strategy: 'apiKey',
      getKey: () => process.env.PAYROLL_KEY!,
      placement: 'header',
      name: 'X-Key',
    },
  },
  methods: { /* ... */ },
})

Every method in this module now hits payroll.internal with a 30-second timeout and API-key auth, while every other module keeps using the global config. This is the config-layering system in action — worth its own full post next, since it’s the thing every other feature in this library builds on top of.

What sticks from this post

  • ctx.request returns a full ApiResponse<T> envelope — return .data for a clean API, or the whole thing when callers need status or headers.

  • fromCache is the cheap way to show “this is stale, refreshing” UI without extra state.

  • Composed calls combine multiple endpoints into one typed method via ctx.client — but keep composition scoped to genuine one-screen needs, not a shortcut around writing a second method.

  • Module-level config overrides global settings per module — the first real taste of the config-layering system, which is next.

  • developerehsan

ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.