DEV Community

dodou
dodou

Posted on

Build a Type-Safe SERP API Client in TypeScript (fetch + Zero Dependencies)

By the time you wire a SERP API into a real product, you've usually made the same deal three times in three files: a fetch call, a type assertion, and a prayer that the response shape matches. This post collapses all three into one small, dependency-free client. ~80 lines of TypeScript gives you typed organic results, discriminated errors, and a call site that reads like const results = await serp.search({ q }).

Types first, code second

The response has two layers worth modeling: the envelope (status, timings, credits) and the payload (the organic array). Model both:

// serp.ts
export interface SearchParams {
  q: string;
  hl?: string;      // defaults to "en" server-side
  gl?: string;      // defaults to "us" server-side
  page?: number;    // 1-based
  device?: "default" | "pc" | "mobile"; // search endpoint only
}

export interface OrganicResult {
  rank: number;        // 1-based position within this response
  title: string;
  link: string;        // always present
  display_url?: string;
  snippet?: string;
  date?: string;       // raw date text as extracted from the snippet
}

export interface SearchOk {
  status: 0;
  request_id: string;
  elapsed_ms: number;
  credits_charged: number;
  organic: OrganicResult[];
}

export interface SerpError {
  status: number;      // non-zero
  error: string;
  request_id?: string;
  credits_charged?: 0;
}

export type SearchResponse = SearchOk | SerpError;
Enter fullscreen mode Exit fullscreen mode

Two deliberate choices: status: 0 on the success branch turns the envelope into a discriminated union, so every consumer must narrow before touching organic. And link is required while snippet is optional — that matches the documented field table for the SerpBase search endpoint docs, where link is the only guaranteed field per result.

The client

const API_URL = "https://api.serpbase.dev/google/search";

export class SerpApiError extends Error {
  constructor(
    public code: number,
    public requestId: string | undefined,
    message: string,
  ) {
    super(`SERP API error ${code}: ${message}`);
    this.name = "SerpApiError";
  }
}

export function createClient(apiKey = process.env.SERPBASE_API_KEY!) {
  async function search(params: SearchParams): Promise<SearchOk> {
    if (!params.q?.trim()) throw new RangeError("q is required");

    const resp = await fetch(API_URL, {
      method: "POST",
      headers: {
        "X-API-Key": apiKey,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(params),
    });

    const data = (await resp.json()) as SearchResponse;

    if (data.status !== 0) {
      throw new SerpApiError(data.status, data.request_id, data.error);
    }
    return data;
  }

  /** First organic hit for a domain, or undefined. */
  async function positionOf(q: string, domain: string) {
    const { organic } = await search({ q });
    return organic.find((r) => r.link.includes(domain));
  }

  return { search, positionOf };
}
Enter fullscreen mode Exit fullscreen mode

The call site — the part your teammates will read a hundred times:

const serp = createClient();

const best = await serp.positionOf("best mechanical keyboard", "example.com");
if (best) console.log(`#${best.rank}: ${best.title}`);
Enter fullscreen mode Exit fullscreen mode

Reading the error surface

The error codes worth branching on in application code are few: 1001 means your key is wrong, 1020 means the account is out of credits, and 1029 means rate limiting — the only one that merits automatic retry, with backoff. Everything else (upstream timeouts, malformed requests) deserves a logged request_id and a failed job, not a silent retry.

Note what the client does not do: it doesn't retry on status !== 0 responses that consumed no credits (credits_charged is 0 on errors), and it doesn't hide failures behind undefined. Errors here are loud so that dashboards can't quietly show stale data.

Test it without spending credits

Inject the fetch or run against a recorded fixture — one JSON file per query is enough:

import { search } from "./fixture"; // recorded response

const client = createClient("test-key");
jest.spyOn(global, "fetch").mockResolvedValue(new Response(JSON.stringify(search)));
Enter fullscreen mode Exit fullscreen mode

Because the parser boundary is a single function from Response to SearchOk, one fixture per status covers the whole unit surface. Keep a single live test (mirroring the fixture approach) to catch schema drift.

FAQ

Why not generate types from the docs with an OpenAPI spec? If the API published one, sure. Hand-rolled interfaces for a single endpoint are cheaper than a codegen pipeline — and the field table is small enough to review by eye.

Why throw instead of returning a Result type? For an internal client, exceptions at the boundary are fine; the discriminated union already forces narrowing on the shape check. If you're building a library, swap the throw for Result<SearchOk, SerpApiError> — the union type stays the same.

What about the other endpoints (news, images, maps)? Same envelope, different payload key and params. Copy the SearchOk interface, rename organic to news/images/places, and tighten the param type — the structure repeats.

Start with the 80 lines above, keep the fixtures next to the client, and resist the urge to wrap it in a class hierarchy — a function and a union type are the whole design.

Top comments (0)