An API Layer Worth Having

One client module holds the base URL, the auth header and the error shape, so components call typed functions instead of fetch.

Syntaxrequest<T>(path: string, init?: RequestInit): Promise<T>

A component that calls fetch directly takes on work that is not its own: the base URL, the Authorization header, what a 404 means, and how an error reaches the template. Repeat that in twelve components and changing the host becomes twelve edits.

One client, then one module per resource

An API layer is two thin levels. The client, src/api/client.ts, owns transport: it joins the base URL to the path, sets headers, checks response.ok and throws one error type. A resource module, src/features/invoices/api.ts, owns meaning: it names the endpoints and declares what comes back.

Typed responses

response.json() returns Promise<any>, which silently defeats type checking. Make the client generic instead, so the call site states the shape: request<Invoice[]>('/invoices'). That is an assertion, not a validation, so the type is a claim about the server, and a schema library is what turns it into a check.

Shorthand and longhand

Give the client small helpers, and know what they expand to. These two lines do the same thing:

get<Invoice[]>('/invoices')
request<Invoice[]>('/invoices', { method: 'GET' })

The helper is for everyday calls; the long form is there when you need an AbortSignal or a different Accept header.

What trips people up

A 204 response has no body, so await response.json() throws on a successful delete. Check the status first. The example component renders a list of invoice numbers, a one-line message while the request is in flight, and an error message if it fails.

Example

Example · vue
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { fetchInvoices } from '@/features/invoices/api'
import { toMessage } from '@/features/invoices/messages'
import type { Invoice } from '@/features/invoices/types'

const invoices = ref<Invoice[]>([])
const error = ref<string | null>(null)
const loading = ref(true)

onMounted(async () => {
  try {
    invoices.value = await fetchInvoices('open')
  } catch (err) {
    error.value = toMessage(err)
  } finally {
    loading.value = false
  }
})
</script>

<template>
  <p v-if="loading">Loading invoices</p>
  <p v-else-if="error">{{ error }}</p>
  <ul v-else>
    <li v-for="invoice in invoices" :key="invoice.id">
      {{ invoice.number }} ({{ invoice.status }})
    </li>
  </ul>
</template>

<style scoped>
ul {
  padding-left: 1rem;
}
</style>

When to use it

  • Pointing the whole application at a staging host by changing one environment variable.
  • Adding an Authorization header to every request without editing a single component.
  • Giving a 422 from the server a consistent message everywhere it can happen.

More examples

The client module

Every request in the application goes through this function, so the base URL, the token and the error shape each exist once. useAuthStore() is called inside request, not at module scope, because a Pinia store can only be used after app.use(createPinia()) has run.

Example · ts
// src/api/client.ts
import { useAuthStore } from '@/stores/auth'

const baseUrl = import.meta.env.VITE_API_BASE_URL

export class ApiError extends Error {
  status: number
  body: unknown

  constructor(status: number, body: unknown, message: string) {
    super(message)
    this.name = 'ApiError'
    this.status = status
    this.body = body
  }
}

export async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
  const auth = useAuthStore()
  const headers = new Headers(init.headers)
  headers.set('Accept', 'application/json')
  if (auth.accessToken) {
    headers.set('Authorization', `Bearer ${auth.accessToken}`)
  }
  if (init.body !== undefined) {
    headers.set('Content-Type', 'application/json')
  }

  const response = await fetch(`${baseUrl}${path}`, { ...init, headers })
  if (!response.ok) {
    const body = await response.text()
    throw new ApiError(response.status, body, `${init.method ?? 'GET'} ${path} failed`)
  }
  if (response.status === 204) {
    return undefined as T
  }
  return (await response.json()) as T
}

export const get = <T>(path: string) => request<T>(path, { method: 'GET' })
export const post = <T>(path: string, body: unknown) =>
  request<T>(path, { method: 'POST', body: JSON.stringify(body) })

A resource module per feature

This is the only file that knows the invoice endpoints exist, and the only place that unwraps the server's { data, total } envelope. Components import fetchInvoices and never see a URL, so renaming the endpoint is a one-file change.

Example · ts
// src/features/invoices/api.ts
import { get, post } from '@/api/client'
import type { Invoice, InvoiceDraft } from './types'

interface InvoiceListResponse {
  data: Invoice[]
  total: number
}

export async function fetchInvoices(status?: Invoice['status']): Promise<Invoice[]> {
  const query = status ? `?status=${encodeURIComponent(status)}` : ''
  const response = await get<InvoiceListResponse>(`/invoices${query}`)
  return response.data
}

export function createInvoice(draft: InvoiceDraft): Promise<Invoice> {
  return post<Invoice>('/invoices', draft)
}

// Output: fetchInvoices('open') resolves to an array of Invoice
// Output: fetchInvoices() requests /invoices with no query string

Turning a status code into a sentence

A thrown ApiError carries a status; a template needs a sentence. Mapping the two in one small function keeps that wording out of components and makes it testable without a server. Reach for it as soon as a second component has to show the same failure.

Example · ts
// src/features/invoices/messages.ts
import { ApiError } from '@/api/client'

export function toMessage(error: unknown): string {
  if (error instanceof ApiError) {
    if (error.status === 404) return 'That invoice no longer exists.'
    if (error.status === 422) return 'Some fields need attention.'
    if (error.status >= 500) return 'The server is having trouble. Try again shortly.'
  }
  return 'Something went wrong.'
}

// Output: toMessage(new ApiError(404, null, 'GET /invoices/9 failed'))
//   === 'That invoice no longer exists.'
// Output: toMessage(new TypeError('Failed to fetch')) === 'Something went wrong.'

Discussion

  • Be the first to comment on this lesson.