
type-safe email agent patterns with Zod
Six Zod schema patterns that give your TypeScript email agent runtime type safety, from inbound payload guards to LLM output sanitizers.
TypeScript tells you at compile time that email.subject is a string. It can't tell you at runtime that the webhook payload your agent just received actually has a subject field.
This is the gap that burns most agent-side email systems. Static types evaporate the moment real data crosses a network boundary: an inbound email payload from an unknown sender, or an LLM generating a draft message with hallucinated fields. TypeScript's compiler did its job at build time. Now your agent is alone, parsing JSON from the outside world with zero guarantees.
Zod closes that gap. You define a schema once and get both runtime validation and a TypeScript type from the same source of truth. No drift between your type definitions and your validation logic. No as unknown as EmailPayload casts that quietly swallow malformed data.
Here are the patterns that hold up in production.
Type-safe email agent patterns with Zod#
Type-safe email agent patterns in TypeScript use Zod schemas to validate and type-narrow email data at runtime, bridging the gap that TypeScript's static analysis cannot cover. The following patterns cover the most common scenarios in agent-first email infrastructure.
- Inbound payload guard — validates raw email data (sender, subject, body, headers) as it enters your agent, rejecting malformed payloads before they reach business logic.
- Agent message contract — defines the exact shape of messages passed between agents in a multi-agent pipeline (ingestion → routing → delivery), preventing silent schema drift.
- LLM output sanitizer , validates and narrows LLM-generated email content (recipient lists, subject lines, body text) before dispatch, catching hallucinated fields and invalid addresses.
- Webhook schema validator , types and validates inbound webhook events (delivery confirmations, bounce notifications, spam reports) from email providers.
- Shared schema package , a single canonical Zod schema published as an internal package, imported by every service that touches email data.
- Error recovery handler , parses Zod validation failures into structured error objects that agents can reason about and retry intelligently.
These aren't theoretical. Let's build them.
Defining an email payload schema#
Start with the core object your agent works with: an email message.
import { z } from 'zod';
const EmailPayloadSchema = z.object({
from: z.string().email(),
to: z.array(z.string().email()).min(1),
cc: z.array(z.string().email()).optional(),
bcc: z.array(z.string().email()).optional(),
replyTo: z.string().email().optional(),
subject: z.string().min(1).max(500),
body: z.string(),
html: z.string().optional(),
attachments: z
.array(
z.object({
filename: z.string(),
contentType: z.string(),
size: z.number().positive(),
})
)
.optional(),
});
type EmailPayload = z.infer<typeof EmailPayloadSchema>;
That `z.infer` call is doing the real work. It derives a TypeScript type directly from the schema, so your validation logic and your type definition stay in sync permanently. Change the schema, and every function consuming `EmailPayload` gets updated types at compile time. No manual type file to maintain.
Handling optional fields like `cc`, `bcc`, and `replyTo` with `.optional()` means the inferred type correctly marks them as `string[] | undefined` or `string | undefined`. Zod's type narrowing carries through your entire call chain.
## parse() vs safeParse(): picking the right one for agents
Zod gives you two ways to validate data. The choice matters more for autonomous agents than for typical web apps.
`.parse()` throws a `ZodError` on failure. Good for pipeline stages where invalid data should halt execution immediately:
```typescript
try {
const email = EmailPayloadSchema.parse(rawData);
await sendEmail(email);
} catch (err) {
if (err instanceof z.ZodError) {
logger.error('Invalid email payload', err.issues);
}
}
`.safeParse()` returns a discriminated union. No exceptions, no try/catch. This is what you want when the agent needs to reason about failures and choose a recovery path:
```typescript
const result = EmailPayloadSchema.safeParse(rawData);
if (!result.success) {
const missing = result.error.issues
.filter((i) => i.code === 'invalid_type' && i.received === 'undefined')
.map((i) => i.path.join('.'));
await agent.handleMissingFields(missing);
} else {
await sendEmail(result.data);
}
For most agent workflows, .safeParse() is the better default. Agents that throw uncaught exceptions crash entire pipelines. Agents that receive structured error data can make decisions.
Validating LLM-generated email content#
This is where Zod earns its place in agent architectures. When an LLM generates email content, you get a JSON blob that probably looks right but might contain hallucinated fields, malformed addresses, or a recipient list that's actually a string instead of an array.
const LlmEmailDraftSchema = z.object({
to: z.preprocess(
(val) => (typeof val === 'string' ? [val] : val),
z.array(z.string().email())
),
subject: z.string().min(1).max(200),
body: z.string().min(1),
});
function validateAgentDraft(llmOutput: unknown) {
const result = LlmEmailDraftSchema.safeParse(llmOutput);
if (!result.success) {
return {
valid: false,
errors: result.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
};
}
return { valid: true, draft: result.data };
}
The z.preprocess call handles a common LLM quirk: the model returns a single email address as a string instead of an array. Rather than rejecting it, the schema normalizes it. This kind of defensive parsing is the difference between an agent that works in demos and one that works at 2 AM when nobody's watching.
If your agent is self-provisioning its own inbox, these validation layers become even more important. The agent operates autonomously, and malformed payloads won't have a human in the loop to catch them.
Webhook events and schema composition#
Email infrastructure generates many event types. Delivery confirmations, bounces, spam complaints, opens, clicks. Each has a different shape, but they share common fields.
Zod's .extend() and z.discriminatedUnion() handle this cleanly:
const BaseEventSchema = z.object({
eventId: z.string().uuid(),
timestamp: z.string().datetime(),
inboxId: z.string(),
});
const DeliveryEvent = BaseEventSchema.extend({
type: z.literal('delivered'),
recipient: z.string().email(),
});
const BounceEvent = BaseEventSchema.extend({
type: z.literal('bounced'),
recipient: z.string().email(),
bounceType: z.enum(['hard', 'soft', 'undetermined']),
diagnosticCode: z.string().optional(),
});
const SpamEvent = BaseEventSchema.extend({
type: z.literal('spam_complaint'),
recipient: z.string().email(),
});
const WebhookEventSchema = z.discriminatedUnion('type', [
DeliveryEvent,
BounceEvent,
SpamEvent,
]);
type WebhookEvent = z.infer<typeof WebhookEventSchema>;
Now your webhook handler gets automatic type narrowing. Check event.type === 'bounced' and TypeScript knows bounceType exists on that branch. No type assertions. No any. The schema is the documentation, and the runtime validation enforces what the types promise.
This pattern works with any email provider that sends webhook events. Whether you're using LobsterMail, SendGrid, Postmark, or a custom SMTP setup, the discriminated union gives you exhaustive type checking across every event shape.
Shared schemas as contracts#
When multiple services touch email data (an ingestion agent, a routing agent, a delivery agent), schema drift is inevitable unless you share the source of truth. Publish your Zod schemas as an internal package:
@yourorg/email-schemas/
src/
email-payload.ts
webhook-events.ts
llm-draft.ts
package.json
Every service imports from the same package. When you add a field or tighten a constraint, one version bump propagates the change everywhere. This is how LobsterMail's SDK approaches it internally: the schema definitions that validate inbound email on the server side are the same shapes available through @lobsterkit/lobstermail.
Tip
If your agents already send and receive email through LobsterMail, the SDK exports typed interfaces for inbox creation, email receiving, and sending. You don't need to redefine those schemas. But for custom processing pipelines between your own services, publishing shared Zod schemas is the right pattern.
Error recovery that agents can reason about#
The last pattern is the one most teams skip. When validation fails, the Zod error object contains structured data about what went wrong. Most code logs it and moves on. Agents can do better.
function categorizeValidationFailure(error: z.ZodError) {
const issues = error.issues;
const missingRequired = issues.filter(
(i) => i.code === 'invalid_type' && i.received === 'undefined'
);
const invalidFormat = issues.filter(
(i) => i.code === 'invalid_string'
);
if (missingRequired.length > 0) {
return { action: 'request_missing', fields: missingRequired.map((i) => i.path) };
}
if (invalidFormat.length > 0) {
return { action: 'reformat', fields: invalidFormat.map((i) => i.path) };
}
return { action: 'reject', reason: 'Unrecoverable validation failure' };
}
The agent doesn't just know validation failed. It knows why and can pick a strategy: request the missing data, attempt reformatting, flag it for human review, or reject and move on. This is the difference between a brittle pipeline and a resilient one.
Where to start#
Pick two schemas: one for the email your agent sends, one for the data it receives. Use .safeParse() everywhere. Add webhook event schemas when you integrate with a provider. Publish shared schemas once you have more than one service touching the same data.
These patterns work regardless of email infrastructure. If you want the agent to provision its own inbox without you configuring anything, . The Zod schemas are your safety net for everything that happens after the inbox exists.
Frequently asked questions
What is Zod and why use it for email schema validation in TypeScript?
Zod is a TypeScript-first schema validation library that lets you define a schema and infer a TypeScript type from it in one step. For email agents, it bridges the gap between compile-time type safety and runtime data validation, catching malformed payloads, hallucinated LLM fields, and webhook format changes before they cause failures.
How do you validate an email address in TypeScript with Zod?
Use z.string().email() to validate that a string is a properly formatted email address. For arrays of recipients, chain it: z.array(z.string().email()).min(1). Zod's built-in email check follows standard format rules, though you can pass a custom regex if you need stricter validation.
How does z.infer derive TypeScript types from a Zod schema?
When you write type MyType = z.infer<typeof MySchema>, Zod walks the schema definition at the type level and produces a matching TypeScript type. This means your runtime validation schema and your static type are always in sync. Change the schema, and every consumer of that type gets updated automatically.
When should an email agent use .parse() versus .safeParse()?
Use .parse() when invalid data should halt execution (e.g., a critical pipeline stage). Use .safeParse() when the agent needs to inspect the error and decide what to do next. For most autonomous agent workflows, .safeParse() is safer because it returns structured errors instead of throwing exceptions.
How do you model a full email payload with Zod, including optional fields like cc and bcc?
Define required fields like from, to, subject, and body normally, then mark optional fields with .optional(). For example, cc: z.array(z.string().email()).optional(). Zod's inferred type will correctly mark these as string[] | undefined, and validation will pass whether they're present or absent.
How do you validate LLM-generated email content with Zod before sending?
Wrap the LLM output in a .safeParse() call with a schema that accounts for common LLM quirks. Use z.preprocess() to normalize single strings into arrays where arrays are expected. This catches hallucinated fields, missing recipients, and malformed addresses before the email leaves your system.
Can Zod validate inbound webhook data from email providers like SendGrid or Postmark?
Yes. Define a Zod schema for each webhook event type, then use z.discriminatedUnion() to validate the incoming payload against the correct schema based on a type field. This gives you automatic type narrowing in your handler code.
How can Zod schemas act as shared contracts between TypeScript microservices?
Publish your Zod schemas as an internal npm package that every service imports. When a schema changes, one version bump propagates the update everywhere. This prevents the schema drift that happens when multiple services define their own versions of the same email data shape.
What error shape does Zod return when validation fails, and how do agents use it?
Zod returns a ZodError with an issues array. Each issue includes a code (like invalid_type or invalid_string), a path pointing to the failing field, and a human-readable message. Agents can categorize these issues to decide whether to retry, request missing data, or reject the input entirely.
How does Zod compare to Yup for TypeScript email validation?
Zod is TypeScript-first and infers types natively from schemas. Yup was designed for JavaScript and added TypeScript support later, so its type inference is less precise. For agent architectures where type safety across service boundaries matters, Zod's tighter TypeScript integration is a meaningful advantage.
What are the performance trade-offs of Zod runtime validation at scale?
Zod validation adds microseconds per call for typical email payload sizes. In high-throughput pipelines processing thousands of emails per second, you can validate at the boundary (ingestion and egress) rather than at every internal hop. The cost is negligible compared to the network latency of sending or receiving the email itself.
Can Zod validate data at runtime in a Node.js application?
Yes, that's its primary purpose. Unlike TypeScript's static types (which are erased at runtime), Zod schemas execute validation checks at runtime. This makes them ideal for validating data that enters your application from external sources: API responses, webhook payloads, LLM outputs, and user input.
How do you extend or compose Zod schemas for different email event types?
Use BaseSchema.extend() to add fields to a base schema, and z.discriminatedUnion() to combine multiple event schemas into a single validator. This lets you share common fields (like eventId and timestamp) while enforcing type-specific shapes for delivery events, bounce events, and spam complaints.
How do Zod validation failures propagate through an agent workflow?
With .parse(), failures throw exceptions that bubble up unless caught. With .safeParse(), failures return a result object the agent can inspect without crashing. The recommended pattern is to categorize failures (missing fields vs. format errors vs. type mismatches) and let the agent pick a recovery strategy for each category.
Does LobsterMail's SDK use Zod for email type safety?
LobsterMail's SDK exports typed TypeScript interfaces for all its operations (inbox creation, sending, receiving). If you're using the SDK directly, those types are already handled. Zod schemas are most useful when you're building custom pipelines between your own services or validating LLM-generated email content before passing it to the SDK.


