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.

TypeScript
// 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.

TypeScript
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.

TypeScript
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.