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.

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

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

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