Email verification in TypeScript
TypeScript gets something the other languages here do not: the compiler can force you to handle the unknown verdict. Model result as a union of string literals and an exhaustive switch stops compiling the day you forget one.
That is not a style preference. The unknown branch is the one teams skip, because in testing it almost never fires, and then a Microsoft-hosted domain throttles on a Tuesday and half a batch falls through a case that was never written.
Built-in fetch, with the response typed as a discriminated union and a never guard in the default case.
Verify one address
You branch on four results: valid, invalid, catch_all and unknown. Role and disposable are boolean flags beside the result, so test them separately. Unknown is the branch most code forgets, and it is never billed.
// The four values of the `result` field. role and disposable are NOT here: they are flags.
type Result = "valid" | "invalid" | "catch_all" | "unknown";
export interface VerifyResult {
email: string;
result: Result;
reason: string; // a closed list today, but it can grow: never switch on it exhaustively
domain: string;
mx_found: boolean;
mx_host: string | null;
smtp_code: string | null;
role: boolean;
disposable: boolean;
free_provider: boolean;
did_you_mean: string | null;
billed: boolean;
checked_at: string;
}
export interface ApiError {
error: { type: string; code: string; message: string; request_id: string };
}
export async function verify(email: string, key: string): Promise<VerifyResult> {
const res = await fetch("https://api.zapbounce.com/v1/verify", {
method: "POST",
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) {
const { error } = (await res.json()) as ApiError;
throw new ZapBounceError(error.code, error.message, error.request_id, res.status);
}
return (await res.json()) as VerifyResult;
}Verify a list
Submit a batch rather than looping the single endpoint. We pace probes per receiving mail host, which protects the sending reputation your results depend on.
interface Page<T> { data: T[]; has_more: boolean; next_cursor: string | null }
interface ResultRow { email: string; result: Result; role: boolean; disposable: boolean; billed: boolean }
export async function* rows(batchId: string, key: string): AsyncGenerator<ResultRow> {
let cursor: string | null = null;
do {
const url = new URL(`https://api.zapbounce.com/v1/batches/${batchId}/results`);
url.searchParams.set("limit", "1000");
if (cursor) url.searchParams.set("cursor", cursor);
const page = (await fetch(url, {
headers: { Authorization: `Bearer ${key}` },
}).then((r) => r.json())) as Page<ResultRow>;
yield* page.data;
cursor = page.has_more ? page.next_cursor : null;
} while (cursor);
}
// The exhaustive switch. Add a value to the union and this stops compiling.
function route(row: ResultRow): Action {
// Flags first. They are booleans beside the result, so check them by hand.
if (row.disposable) return { suppress: row.email };
switch (row.result) {
case "valid":
return row.role ? { segment: "shared-mailbox", email: row.email } : { send: row.email };
case "catch_all": return { segment: "accepts-everything", email: row.email };
case "unknown": return { recheck: row.email };
case "invalid": return { suppress: row.email };
default: {
const exhaustive: never = row.result;
throw new Error(`unhandled result ${exhaustive}`);
}
}
}Errors and retries
A 429 and a 5xx are worth retrying. A 402 for credits and a 400 for a malformed address will fail the same way on every attempt, so stop.
export class ZapBounceError extends Error {
constructor(
readonly code: string,
message: string,
readonly requestId: string,
readonly status: number,
) {
super(message);
this.name = "ZapBounceError";
}
/** 400 and 402 fail identically forever. Only these are worth another attempt. */
get retryable(): boolean {
return this.status === 429 || this.status >= 500;
}
}
export async function withRetry<T>(fn: () => Promise<T>, attempts = 5): Promise<T> {
let lastError: unknown;
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (err) {
lastError = err;
if (err instanceof ZapBounceError && !err.retryable) throw err;
await new Promise((r) => setTimeout(r, 2 ** i * 500 * (0.5 + Math.random())));
}
}
throw lastError;
}TypeScript: common questions
Do you ship types?
The interfaces on this page are the whole surface. Copy them into your project rather than taking a dependency for four endpoints.
Why a union instead of a string?
Because string lets you forget the unknown branch and the compiler says nothing. The union plus a never default turns that omission into a build error.
Should I use zod?
At the edge of a service that other people's addresses flow through, yes. A cast will happily accept a shape change; a schema will not.
Run this TypeScript code today
100 free checks a month, no card, credits that never expire, and unknown results that cost nothing.