Email verification in TypeScript
TypeScript gets something the other languages here do not: the compiler can force you to handle the unknown verdict. Model status 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 statuses: valid, invalid, catch_all and unknown. Role and disposable arrive in the flags array beside the status, so test them separately. Unknown is the branch most code forgets, and its credit is refunded.
// The four values of the `status` field. role and disposable are NOT here: they are flags.
type Status = "valid" | "invalid" | "catch_all" | "unknown";
type Flag = "role" | "disposable" | "free_provider";
export interface VerifyResult {
id: string;
email: string;
status: Status;
reasonCode: string; // MAILBOX_EXISTS, MAILBOX_NOT_FOUND, TIMEOUT... the list can grow: never switch on it exhaustively
provider: string | null;
flags: Flag[];
smtpCode: number | null;
checkedAt: string;
}
export interface ApiError {
statusCode: number;
code: string; // CREDITS_INSUFFICIENT, RATE_LIMITED, UNAUTHORIZED...
message: string;
requestId: string;
}
export async function verify(email: string, key: string): Promise<VerifyResult> {
const res = await fetch("https://app.zapbounce.com/api/v1/verify", {
method: "POST",
headers: { "X-API-Key": key, "Content-Type": "application/json" },
body: JSON.stringify({ emails: [email] }),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) {
const err = (await res.json()) as ApiError;
throw new ZapBounceError(err.code, err.message, err.requestId, res.status, res.headers);
}
const { results } = (await res.json()) as { node: string; results: VerifyResult[] };
return results[0];
}Verify a list
Submit a job rather than looping the single endpoint. We pace probes per receiving mail host, which protects the sending reputation your results depend on.
type JobStatus = "pending" | "running" | "completed" | "partial" | "failed" | "cancelled";
interface Job { id: string; status: JobStatus; totalEmails: number; validEmails: number; invalidEmails: number; catchAllEmails: number; otherEmails: number }
interface Page<T> { items: T[]; meta: { page: number; pageSize: number; total: number; totalPages: number } }
interface ResultRow { email: string; status: Status; reasonCode: string; flags: Flag[] }
const API = "https://app.zapbounce.com/api/v1";
const DONE: JobStatus[] = ["completed", "partial", "failed", "cancelled"];
export async function waitForJob(id: string, key: string, everyMs = 5000): Promise<Job> {
for (;;) {
const job = (await fetch(`${API}/jobs/${id}`, { headers: { "X-API-Key": key } })
.then((r) => r.json())) as Job;
if (DONE.includes(job.status)) return job; // no webhook: poll
await new Promise((r) => setTimeout(r, everyMs));
}
}
export async function* rows(jobId: string, key: string): AsyncGenerator<ResultRow> {
let page = 1;
let totalPages = 1;
do {
const res = (await fetch(`${API}/jobs/${jobId}/emails?page=${page}&pageSize=100`, {
headers: { "X-API-Key": key },
}).then((r) => r.json())) as Page<ResultRow>;
yield* res.items;
totalPages = res.meta.totalPages;
} while (page++ < totalPages);
}
// The exhaustive switch. Add a value to the union and this stops compiling.
function route(row: ResultRow): Action {
// Flags first. They are an array beside the status, so check them by hand.
if (row.flags.includes("disposable")) return { suppress: row.email };
switch (row.status) {
case "valid":
return row.flags.includes("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.status;
throw new Error(`unhandled status ${exhaustive}`);
}
}
}Errors and retries
A 429 and a 5xx are worth retrying. A 422 CREDITS_INSUFFICIENT 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,
readonly headers: Headers,
) {
super(message);
this.name = "ZapBounceError";
}
/** 400 and 422 (CREDITS_INSUFFICIENT) fail identically forever. Only these are worth another attempt. */
get retryable(): boolean {
return this.status === 429 || this.status >= 500;
}
/** No Retry-After exists. The window length is the floor for a 429 backoff. */
get windowSeconds(): number {
return Number(this.headers.get("X-RateLimit-Window-Seconds") ?? 1);
}
}
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;
const floor = err instanceof ZapBounceError && err.status === 429 ? err.windowSeconds * 1000 : 500;
await new Promise((r) => setTimeout(r, 2 ** i * floor * (0.5 + Math.random())));
}
}
throw lastError;
}
// On a timeout, fail open: { email, status: "unknown", reasonCode: "TIMEOUT", flags: [] }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 a handful of 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 refunded.