Email Verification in TypeScript: Type-Safe API Integration for Modern Runtimes

Key Takeaways
  • TypeScript's type system catches API integration errors at compile time, preventing runtime surprises when handling verification responses.
  • The EmailVerifierAPI v2 endpoint returns predictable JSON that maps cleanly to TypeScript interfaces with union types for status fields and sub-status codes.
  • The native fetch API (available in Node.js 18+, Deno, and Bun) eliminates the need for third-party HTTP libraries, keeping your dependency footprint minimal.
  • A Promise.allSettled-based batch pattern provides bounded concurrency for high-volume verification with proper error isolation per request.

Why TypeScript for API Integrations

TypeScript has become the default for production JavaScript projects, and for good reason. When integrating with an external API like EmailVerifierAPI, TypeScript's static type system lets you define the exact shape of request parameters and response objects, catch field access errors before runtime, and provide IDE autocompletion that makes working with API responses faster and less error-prone. In a verification integration where routing decisions depend on specific field values ("passed" vs "failed", true vs false), type safety is not a luxury. It prevents the category of bugs that silently misroute addresses because of a typo in a field name.

This guide walks through a complete TypeScript integration with the EmailVerifierAPI v2 endpoint, compatible with Node.js 18+, Deno, and Bun. We use only the native fetch API with zero third-party dependencies.

Testing with cURL

Before writing TypeScript, confirm your API key and review the response structure:

curl -X GET "https://www.emailverifierapi.com/v2/verify?email=test@example.com&apikey=YOUR_API_KEY"

Response:

{
  "status": "passed",
  "isDisposable": false,
  "isFreeService": true,
  "isOffensive": false,
  "isRoleAccount": false,
  "isGibberish": false,
  "smtp_check": "success",
  "sub_status": "mailboxExists"
}

Defining Type-Safe Interfaces

The first step is encoding the API's response contract into TypeScript types. Using union types for the status and sub_status fields ensures your code can only check for values the API actually returns:

type VerifyStatus = "passed" | "failed" | "unknown" | "transient";

type SubStatus =
  | "mailboxExists"
  | "mailboxDoesNotExist"
  | "mailboxIsFull"
  | "domainDoesNotExist"
  | "mxServerDoesNotExist"
  | "invalidSyntax"
  | "isCatchall"
  | "isGreylisting"
  | "transientError";

interface VerifyResponse {
  status: VerifyStatus;
  isDisposable: boolean;
  isFreeService: boolean;
  isOffensive: boolean;
  isRoleAccount: boolean;
  isGibberish: boolean;
  smtp_check: "success" | "error";
  sub_status: SubStatus;
}

interface VerifyResult {
  email: string;
  success: true;
  data: VerifyResponse;
}

interface VerifyError {
  email: string;
  success: false;
  error: string;
}

The Core Verification Function

With types defined, the verification function uses the native fetch API and returns a discriminated union that forces callers to check for success before accessing response data:

const API_BASE = "https://www.emailverifierapi.com/v2/verify";
const API_KEY = process.env.EMAIL_VERIFIER_API_KEY!;

async function verifyEmail(
  email: string
): Promise<VerifyResult | VerifyError> {
  try {
    const url = `${API_BASE}?email=${encodeURIComponent(email)}&apikey=${API_KEY}`;
    const res = await fetch(url, { signal: AbortSignal.timeout(15000) });

    if (!res.ok) {
      return { email, success: false, error: `HTTP ${res.status}` };
    }

    const data: VerifyResponse = await res.json();
    return { email, success: true, data };
  } catch (err) {
    const message = err instanceof Error ? err.message : "Unknown error";
    return { email, success: false, error: message };
  }
}

// Usage:
const result = await verifyEmail("user@example.com");
if (result.success) {
  console.log(result.data.status);      // "passed"
  console.log(result.data.sub_status);  // "mailboxExists"
  console.log(result.data.isDisposable); // false
} else {
  console.error(result.error);
}

Batch Processing with Bounded Concurrency

For processing large lists, you need concurrency control to avoid overwhelming both your system and the API's rate limits. This utility function processes emails in chunks using Promise.allSettled, which ensures that a single failed request does not abort the entire batch:

async function verifyBatch(
  emails: string[],
  concurrency: number = 10
): Promise<(VerifyResult | VerifyError)[]> {
  const results: (VerifyResult | VerifyError)[] = [];

  for (let i = 0; i < emails.length; i += concurrency) {
    const chunk = emails.slice(i, i + concurrency);
    const settled = await Promise.allSettled(
      chunk.map((email) => verifyEmail(email))
    );

    for (const outcome of settled) {
      if (outcome.status === "fulfilled") {
        results.push(outcome.value);
      } else {
        results.push({
          email: chunk[settled.indexOf(outcome)],
          success: false,
          error: outcome.reason?.message ?? "Promise rejected",
        });
      }
    }
  }

  return results;
}

// Usage:
const emails = ["user@example.com", "fake@tempmail.org", "info@company.com"];
const results = await verifyBatch(emails, 5);

const passed = results.filter(
  (r): r is VerifyResult => r.success && r.data.status === "passed"
);
const failed = results.filter(
  (r): r is VerifyResult => r.success && r.data.status === "failed"
);

console.log(`Passed: ${passed.length}, Failed: ${failed.length}`);

Understanding the Response Fields

The "status" field is your primary routing signal. "Passed" means the mailbox exists and is deliverable. "Failed" means the address is definitively invalid. "Unknown" indicates the check was inconclusive, often from a catch-all domain or an unresponsive server. "Transient" signals a temporary server-side error, and re-verification after a delay is recommended.

The boolean flags provide secondary risk signals. An address can pass verification (the mailbox exists) while still being flagged as disposable, role-based, or gibberish. Your application logic should combine the status field with these flags to make routing decisions. For instance, a "passed" address that is also "isDisposable: true" might be valid but inappropriate for a user registration flow that requires a permanent identity.

The "sub_status" field provides the most granular diagnostic information. "mailboxExists" confirms deliverability. "isCatchall" tells you the domain accepts all addresses, so mailbox-level confirmation is impossible. "isGreylisting" indicates the server temporarily rejected the verification probe, and re-checking after a short delay is advisable. These sub-statuses are invaluable for debugging delivery issues and making nuanced list management decisions.

Runtime Compatibility

This code runs without modification on Node.js 18+ (which includes a native fetch implementation), Deno (which has had native fetch since its initial release), and Bun (which supports the full Web API surface). For Node.js versions below 18, you would need to install a fetch polyfill like node-fetch, but the type definitions and logic remain identical. The benefit of building on web-standard APIs is that your verification module is portable across runtimes without any code changes.

Frequently Asked Questions

Do I need to install any HTTP libraries for this integration?

No. Node.js 18+, Deno, and Bun all include native fetch support. The integration shown here has zero third-party runtime dependencies. You only need TypeScript itself for type checking during development; the compiled JavaScript runs on any modern runtime.

How do I handle rate limiting in the batch processor?

The chunk-based approach shown above provides natural rate limiting by processing only N emails concurrently. If you receive HTTP 429 responses, reduce the concurrency parameter and add a delay between chunks using a simple setTimeout wrapper. Start with a concurrency of 10 and adjust based on your API plan's rate allowance.

Why use Promise.allSettled instead of Promise.all?

Promise.all rejects immediately if any single promise fails, which would abort your entire batch if one request times out or returns an error. Promise.allSettled waits for all promises to complete and reports each result individually, ensuring that one bad request does not prevent the other 9 in the chunk from being processed and recorded.

Can I use this code in a Next.js API route or Edge Function?

Yes. The code uses only standard web APIs (fetch, AbortSignal) that are available in Next.js API routes, Vercel Edge Functions, Cloudflare Workers, and other serverless environments. For Edge Functions with short timeout limits, reduce the AbortSignal timeout and handle timeout errors gracefully.