DEV Community

dodou
dodou

Posted on

Type-Safe Google Search Results in TypeScript

If you're building a Node backend in TypeScript, the last thing you want is any[] spilling out of your search integration. The SERP API I use (SerpBase, https://api.serpbase.dev) returns a well-defined JSON envelope, which means you can type the whole integration and get editor autocomplete + compile-time safety for free.

This post builds a typed TypeScript client: response types, a small client class, and a CLI. Node 18+ with global fetch — no extra dependencies.

The response types

First, model the response. The envelope is consistent across endpoints: status, request_id, elapsed_ms, credits_charged, plus endpoint-specific data.

// serp.ts
interface OrganicResult {
  rank: number;
  position?: number;           // alias for rank, optional
  title: string;
  link: string;
  url?: string;
  display_url?: string;
  snippet?: string;            // optional
  sitelinks?: { title: string; link: string }[];
}

interface SearchResponse {
  status: number;              // 0 = success
  request_id: string;
  elapsed_ms: number;
  credits_charged: number;
  search_type: string;
  query: string;
  organic?: OrganicResult[];
  featured_snippet?: {
    title?: string;
    answer?: string;
    link?: string;
  };
  people_also_ask?: { question: string; answer?: string }[];
  related_searches?: string[];
}
Enter fullscreen mode Exit fullscreen mode

Marking snippet, display_url, position as optional reflects the real schema — those fields only appear when Google renders them. TypeScript's ? is exactly the right tool for "this field may be absent."

The typed client

const BASE = "https://api.serpbase.dev";

export class SerpClient {
  constructor(private apiKey: string) {}

  async search(
    query: string,
    opts: { hl?: string; gl?: string; page?: number; device?: "default" | "pc" | "mobile" } = {},
  ): Promise<SearchResponse> {
    const resp = await fetch(`${BASE}/google/search`, {
      method: "POST",
      headers: { "Content-Type": "application/json", "X-API-Key": this.apiKey },
      body: JSON.stringify({ q: query, ...opts }),
    });

    const data = (await resp.json()) as SearchResponse;
    if (data.status !== 0) {
      throw new Error(`Search failed: ${data.status} (${data.request_id})`);
    }
    return data;
  }
}
Enter fullscreen mode Exit fullscreen mode

The device option is typed as a union, so passing "tablet" is a compile error instead of a runtime surprise.

Using it with autocomplete

Now callers get full typing:

const client = new SerpClient(process.env.SERPBASE_API_KEY!);

const data = await client.search("typescript generics", { hl: "en", gl: "us" });

for (const r of data.organic ?? []) {
  console.log(`${r.rank}. ${r.title}`);
  console.log(`   ${r.link}`);
}

// optional fields are typed as optional — safe to guard
if (data.featured_snippet) {
  console.log("Snippet:", data.featured_snippet.answer);
}
Enter fullscreen mode Exit fullscreen mode

No any, no casts to guess at — the editor knows organic is OrganicResult[] | undefined and nudges you to handle the empty case.

A typed CLI wrapper

Small wrapper to make it a one-liner:

// cli.ts
const [, , query = "serp api"] = process.argv;
const client = new SerpClient(process.env.SERPBASE_API_KEY!);
const data = await client.search(query, { hl: "en", gl: "us" });
for (const r of data.organic ?? []) {
  console.log(`${r.rank}. ${r.title}\n   ${r.link}`);
}
Enter fullscreen mode Exit fullscreen mode
npx tsx cli.ts "typescript generics"
Enter fullscreen mode Exit fullscreen mode

Adding a typed cache layer

Since the response is typed, a typed cache wrapper is straightforward:

export class CachedSerpClient {
  private cache = new Map<string, { data: SearchResponse; expireAt: number }>();

  constructor(
    private inner: SerpClient,
    private ttlMs = 15 * 60 * 1000,
  ) {}

  async search(query: string, opts: Parameters<SerpClient["search"]>[1] = {}) {
    const key = `${opts.gl ?? "us"}:${query}`;
    const hit = this.cache.get(key);
    if (hit && hit.expireAt > Date.now()) return hit.data;

    const data = await this.inner.search(query, opts);
    this.cache.set(key, { data, expireAt: Date.now() + this.ttlMs });
    return data;
  }
}
Enter fullscreen mode Exit fullscreen mode

Same method signature, so swapping in the cache is a one-line change — and the types make sure you don't accidentally use a string where a SearchResponse belongs.

The optional-fields pattern to remember

The schema has fields that appear only when Google shows them (snippet, position, sitelinks, featured_snippet). The TS pattern that matches this:

  • Mark them optional with ?
  • Guard with data.field !== undefined or ?? []
  • Never assume — the type system enforces what the docs state

Cost note

/google/search costs 1 credit per request. A small rank-check job running 50 keywords daily is ~1,500 requests/month — around a dollar or two on a standard pack. The free 100 searches on signup cover your build week.

Wrapping up

A typed integration turns "parse the JSON and hope" into "the compiler checks my field access." The full schema (including all optional modules) is in the SerpBase documentation — type the fields you actually use and let ? carry the "only when Google shows it" semantics.

Top comments (0)