TidyURLs
Go to Tools
BLOG
The TidyURLsJournal

Practical guides, tutorials, and insights about URLs, SEO, web development, and online marketing. Learn, troubleshoot, and get the most of the web.

Back to the Journal
TidyURLs Journal

How to Build a Simple REST API Client in TypeScript

Practical insights, clear explanations, and useful ideas for understanding the web.

October 25, 20267 min read5 topics
How to Build a Simple REST API Client in TypeScript
Explore the details
#TypeScript#Fetch API#REST API#Web Development#Runtime Validation
October 25, 20267 min read

A REST API call can look simple: send a request, then read JSON. But a reliable client also needs to handle failed HTTP statuses, network errors, slow requests, malformed JSON, and data that does not match the shape your code expects. TypeScript helps describe the data you intend to use, but it cannot verify what a server actually sent.

This tutorial builds a small fetch-based client that checks HTTP responses, supports cancellation and timeouts, handles errors, and validates response data at runtime.

Key takeaways

  • fetch() generally resolves when a server returns an HTTP error status; check response.ok yourself.
  • Treat a response body as unknown until you validate it.
  • TypeScript types help during development but do not validate untrusted JSON at runtime.
  • Use AbortController to stop waiting for a request, while remembering that cancellation does not guarantee the server stopped processing it.
  • Keep network, HTTP, JSON parsing, and validation failures distinguishable in your application.

Start with a typed response and a fetch request

Suppose an API returns a user with an ID, name, and email address. Define the shape your application expects:

type User = {
  id: number;
  name: string;
  email: string;
};

A basic request might look like this:

const response = await fetch("https://api.example.test/users/42");

if (!response.ok) {
  throw new Error(`Request failed with HTTP ${response.status}`);
}

const user: User = await response.json();

This code checks the HTTP status, but the User annotation does not prove that the JSON contains a number for id or strings for name and email. A type assertion or annotation changes what TypeScript assumes; it does not inspect the response at runtime. The response may be malformed, unexpectedly shaped, or produced by a changed API.

fetch() also rejects for some network-level failures, but an HTTP response such as 404 or 500 is still a response. That is why checking response.ok matters.

Validate JSON before using it

A safer approach is to read the JSON as unknown and narrow it with a runtime check. Here is a small validator for the User shape:

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function isUser(value: unknown): value is User {
  return (
    isRecord(value) &&
    typeof value.id === "number" &&
    typeof value.name === "string" &&
    typeof value.email === "string"
  );
}

The isUser function is a type guard: it returns true only when the value passes the checks. For larger or more complex schemas, a dedicated validation library can reduce repetitive code, but the same principle applies: validate untrusted data at runtime.

Build a reusable JSON request function

The following client handles non-success statuses, invalid JSON, validation failures, network errors, external cancellation, and a timeout. It is intentionally small and expects successful responses to contain JSON. It uses browser APIs available in TypeScript projects with the DOM library enabled.

class HttpError extends Error {
  constructor(readonly status: number) {
    super(`Request failed with HTTP ${status}`);
    this.name = "HttpError";
  }
}

class ResponseValidationError extends Error {
  constructor() {
    super("Response JSON did not match the expected shape");
    this.name = "ResponseValidationError";
  }
}

class RequestTimeoutError extends Error {
  constructor(readonly timeoutMs: number) {
    super(`Request timed out after ${timeoutMs} ms`);
    this.name = "RequestTimeoutError";
  }
}

class RequestAbortedError extends Error {
  constructor() {
    super("Request was cancelled");
    this.name = "RequestAbortedError";
  }
}

async function requestJson<T>(
  input: RequestInfo | URL,
  validate: (value: unknown) => value is T,
  options: RequestInit = {},
  timeoutMs = 10_000,
): Promise<T> {
  const controller = new AbortController();
  const externalSignal = options.signal;
  let timedOut = false;

  const timeoutId = setTimeout(() => {
    timedOut = true;
    controller.abort();
  }, timeoutMs);

  const onExternalAbort = () => controller.abort(externalSignal?.reason);

  if (externalSignal?.aborted) {
    onExternalAbort();
  } else {
    externalSignal?.addEventListener("abort", onExternalAbort, { once: true });
  }

  try {
    const headers = new Headers(options.headers);
    if (!headers.has("Accept")) {
      headers.set("Accept", "application/json");
    }

    const response = await fetch(input, {
      ...options,
      headers,
      signal: controller.signal,
    });

    if (!response.ok) {
      throw new HttpError(response.status);
    }

    const data: unknown = await response.json();

    if (!validate(data)) {
      throw new ResponseValidationError();
    }

    return data;
  } catch (error) {
    if (timedOut) {
      throw new RequestTimeoutError(timeoutMs);
    }

    if (externalSignal?.aborted) {
      throw new RequestAbortedError();
    }

    if (
      error instanceof HttpError ||
      error instanceof ResponseValidationError ||
      error instanceof RequestTimeoutError ||
      error instanceof RequestAbortedError
    ) {
      throw error;
    }

    if (error instanceof SyntaxError) {
      throw new Error("Response body was not valid JSON");
    }

    throw new Error("Network request failed");
  } finally {
    clearTimeout(timeoutId);
    externalSignal?.removeEventListener("abort", onExternalAbort);
  }
}

The timeout begins before the request and includes waiting for the response body to be parsed. The catch block maps common failures to useful error messages. In production, you may want to preserve the original error as a cause, attach request context to logs, or define a shared error type for your application.

Call the client

Pass the validator along with the endpoint. The URL below uses the reserved .test domain as an illustrative example, not a real API.

async function getUser(id: number, signal?: AbortSignal): Promise<User> {
  return requestJson(
    `https://api.example.test/users/${encodeURIComponent(String(id))}`,
    isUser,
    { signal },
  );
}

const user = await getUser(42);
console.log(user.name);

encodeURIComponent is useful when inserting a value into a URL path segment. It does not replace server-side authorization or input validation.

Send JSON with a typed request

The same helper can send a request body. Set the content type when sending JSON, and remember that the response still needs runtime validation.

type NewUser = {
  name: string;
  email: string;
};

async function createUser(user: NewUser): Promise<User> {
  return requestJson(
    "https://api.example.test/users",
    isUser,
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(user),
    },
  );
}

This example assumes the endpoint returns a JSON User after creation. An API that returns 204 No Content needs a different helper or response-handling branch: calling response.json() when there is no JSON body will fail.

Common mistakes and troubleshooting

Assuming non-2xx responses reject fetch

A 404 or 500 does not, by itself, make fetch() reject. Check response.ok or the status code before treating the response as successful.

Trusting a TypeScript annotation as validation

Writing const data: User = await response.json() does not inspect the payload. Parse into unknown, then validate it before using it as a User.

Parsing a response body more than once

A response body is a stream and is normally consumed once. If you need to inspect an error body, read it once and store the result rather than calling response.json() and response.text() independently on the same response.

Treating every failure as the same problem

Network failures, HTTP errors, invalid JSON, schema mismatches, timeouts, and user cancellations have different causes. Keeping them distinct makes it easier to show an appropriate message, retry only when sensible, and diagnose problems.

Expecting cancellation to undo server work

Aborting a browser request stops the client from waiting for the operation. It does not guarantee that the server has not already received or processed the request. For actions that change data, use appropriate server-side design, such as idempotency where needed.

Practical recommendations

  • Give each API operation a clear response type and a runtime validator.
  • Check status codes before parsing a success body.
  • Decide explicitly how to handle empty responses such as 204 No Content.
  • Set timeouts based on the application’s needs; a timeout is a client-side waiting limit, not a server execution limit.
  • Pass an AbortSignal when a request should be cancelled, such as when a view is closed or a newer search replaces an older one.
  • Avoid exposing sensitive response bodies or implementation details in user-facing error messages.
  • If you need to inspect a URL’s components while debugging, you can inspect URL components. Parsing a URL is useful for understanding it, but it is not a substitute for request validation or other security controls.

Conclusion

A dependable TypeScript REST API client does more than attach a type to response.json(). It checks HTTP status, handles cancellation and timeouts, distinguishes common failures, and validates server data at runtime. Start with a small helper like this, then adapt its response handling and error model to the behavior of the APIs your application actually uses.