Parsing, validating and modelling responses

Handle the envelope shapes APIs return, check data at the boundary before it reaches your UI, and map wire formats into the models your app uses.

Envelope shapes

ShapeExample field pathsNotes
Bare object or arraythe body itselfsimple, but there is nowhere to put metadata
Data wrapperdatathe most common; keeps room for meta
Data with metadatadata, meta.page, meta.totalpagination lives here
Error envelopeerror.code, error.message, error.detailsbranch on the code, display the message
HAL or JSON:API styledata, included, relationshipsrelational payloads; resolve references before rendering
// unwrap once, at the boundary, so no component ever sees a wrapper
function unwrap(body) {
  if (body && typeof body === 'object' && 'data' in body) {
    return { data: body.data, meta: body.meta || null };
  }
  return { data: body, meta: null };
}

// an error envelope is not always at the top level
function errorOf(body, status) {
  const e = body && body.error;
  if (e && typeof e === 'object') {
    return { status: status, code: e.code || 'unknown', message: e.message || 'Request failed', details: e.details || null };
  }
  return { status: status, code: 'unknown', message: 'HTTP ' + status, details: null };
}
  • Check that a 200 response is JSON before parsing it. A proxy or a login page can return HTML with a 200, and res.json() then throws a syntax error that looks like a bug in your code.
  • res.headers.get('content-type') is a hint, not a guarantee. Try the parse and handle the throw.
  • Keep the envelope handling in one function. If every component knows the wrapper, changing the API is a refactor instead of a one-line edit.
  • Do not assume an array. An endpoint that returns a single object for one result and an array for many produces a bug that only appears with a second record.

Validating at the boundary

TypeScript types are erased at runtime, so a typed variable is an assertion about the server, not a check on it. Validate the fields you depend on as soon as the response is read, and fail loudly at that point rather than three components later.

class SchemaError extends Error {
  constructor(message, payload) {
    super(message);
    this.name = 'SchemaError';
    this.payload = payload;              // keep the raw body for the error report
  }
}

function expect(shape, value) {
  const out = {};
  for (const key of Object.keys(shape)) {
    const check = shape[key];
    const raw = value == null ? undefined : value[key];
    if (raw === undefined || raw === null) throw new SchemaError('missing field: ' + key, value);
    if (typeof raw !== check) throw new SchemaError('field ' + key + ' should be ' + check, value);
    out[key] = raw;
  }
  return out;
}

const Task = body => expect({ id: 'number', title: 'string', done: 'boolean' }, body);
const list = unwrap(await res.json()).data.map(Task);
  • Validate the shape you consume, not the whole payload. A strict schema over every optional field turns a harmless server-side addition into an outage.
  • On failure, include the request URL and the raw body in the error. Without them the report reads as a client bug and the payload is gone by the time anyone looks.
  • A dedicated schema library pays for itself once the shapes are non-trivial; the pattern above is the small version of the same idea.
  • Validate on every read, not once at application start. Responses change when the server changes, which is exactly when you need the error.

Dates, nulls and mapping

// JSON has no date type: the wire carries a string
const raw = { due: '2026-09-18T10:00:00Z', closed: null, note: undefined };

// distinguish the three states explicitly
raw.closed === null;          // present and empty
raw.note === undefined;       // absent from the payload
'due' in raw;                 // present, whatever the value

// map to an app model once, keeping the wire names out of the UI
function toTask(dto) {
  return {
    id: dto.id,
    title: dto.title,
    due: dto.due ? new Date(dto.due) : null,      // parse at the boundary
    closed: dto.closed !== null && dto.closed !== undefined
  };
}

// sending a date back: always ISO 8601 in UTC, never a locale string
const body = JSON.stringify({ due: task.due.toISOString() });
⚠️
A timestamp without a timezone is a bug waiting for a user in another region. Parse at the boundary, store a Date or an instant, and format only when rendering. Never let a date string travel through three layers before it is parsed.

FAQ

Should I trust the Content-Type header?
Read it, but do not rely on it alone. Check that the response is ok, then try to parse, and handle the failure. Some proxies and error pages return HTML with a JSON content type.
Why validate if I already have TypeScript types?
Types are checked at compile time and erased at runtime. They describe what you believe the server sends. Validation is what tells you when the belief is wrong, and it is the only one of the two that runs in production.

Building an API client layer Caching and conditional requests

Last refreshed 2026-09-18.