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[];
}
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;
}
}
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);
}
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}`);
}
npx tsx cli.ts "typescript generics"
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;
}
}
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 !== undefinedor?? [] - 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)