Skip to content

Zod Integration

Turn Zod validation errors into typed Results for seamless composition in workflows.

  • Type-safe validation errors — Know exactly what went wrong, not just “validation failed”
  • Composable with other operations — Chain validation with database calls, API requests, etc.
  • Early exit on invalid inputstep() stops the workflow immediately on validation failure
import { z } from 'zod';
import { ok, err, type Result } from 'awaitly';
import { createWorkflow } from 'awaitly/workflow';
const UserSchema = z.object({
email: z.string().email(),
age: z.number().min(18),
});
type User = z.infer<typeof UserSchema>;
type ValidationError = { type: 'VALIDATION'; issues: z.ZodIssue[] };
// Convert Zod's safeParse to a Result
const zodToResult = <T>(
schema: z.ZodSchema<T>,
data: unknown
): Result<T, ValidationError> => {
const parsed = schema.safeParse(data);
return parsed.success
? ok(parsed.data)
: err({ type: 'VALIDATION', issues: parsed.error.issues });
};
// Use in a workflow
const workflow = createWorkflow('workflow', { validate: (data: unknown) => Promise.resolve(zodToResult(UserSchema, data)),
});
const result = await workflow.run(async ({ step, deps }) => {
const user = await step('validate', () => deps.validate({ email: 'test@example.com', age: 25 }));
return user; // User type, not unknown
});

The simplest pattern—validate input and return a typed Result:

import { z } from 'zod';
import { ok, err, type Result } from 'awaitly';
// Define your schema
const CreateUserSchema = z.object({
email: z.string().email('Invalid email format'),
password: z.string().min(8, 'Password must be at least 8 characters'),
name: z.string().optional(),
});
type CreateUserInput = z.infer<typeof CreateUserSchema>;
// Validation error with full issue details
type ValidationError = {
type: 'VALIDATION';
issues: z.ZodIssue[];
};
// Generic helper (copy this to your utils)
export const zodToResult = <T>(
schema: z.ZodSchema<T>,
data: unknown
): Result<T, ValidationError> => {
const parsed = schema.safeParse(data);
return parsed.success
? ok(parsed.data)
: err({ type: 'VALIDATION', issues: parsed.error.issues });
};
// Usage
const input = zodToResult(CreateUserSchema, requestBody);
// input: Result<CreateUserInput, ValidationError>

Compose validation with other operations:

import { createWorkflow } from 'awaitly/workflow';
import { zodToResult } from './utils';
const deps = {
validateInput: (raw: unknown) => Promise.resolve(zodToResult(CreateUserSchema, raw)),
saveToDatabase,
sendWelcomeEmail,
};
const createUserWorkflow = createWorkflow('workflow', deps);
const createUser = async (rawInput: unknown) => {
return createUserWorkflow.run(async ({ step, deps }) => {
// Validate input first — exits early if invalid
const input = await step('validateInput', () => deps.validateInput(rawInput));
// Now input is typed as CreateUserInput
const user = await step('saveToDatabase', () => deps.saveToDatabase(input));
await step('sendWelcomeEmail', () => deps.sendWelcomeEmail(user.email));
return user;
});
};
// Error type is automatically: ValidationError | DbError | EmailError | UnexpectedError

Return validation results to forms:

import { z } from 'zod';
import { zodToResult } from './utils';
const SignUpSchema = z.object({
email: z.string().email('Please enter a valid email'),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain an uppercase letter')
.regex(/[0-9]/, 'Password must contain a number'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
// In your form handler
const handleSubmit = async (formData: FormData) => {
const raw = Object.fromEntries(formData);
const validation = zodToResult(SignUpSchema, raw);
if (!validation.ok) {
// Convert Zod issues to field errors for your form library
const fieldErrors = validation.error.issues.reduce((acc, issue) => {
const field = issue.path.join('.');
acc[field] = issue.message;
return acc;
}, {} as Record<string, string>);
return { success: false, errors: fieldErrors };
}
// Proceed with valid data
const result = await createUser(validation.value);
return result.ok
? { success: true, user: result.value }
: { success: false, error: result.error };
};

Validate incoming API requests:

import { createWorkflow } from 'awaitly/workflow';
import { zodToResult } from './utils';
// Define request schemas
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
tags: z.array(z.string()).max(10).optional(),
});
const deps = {
validatePost: (raw: unknown) => Promise.resolve(zodToResult(CreatePostSchema, raw)),
createPost,
};
const workflow = createWorkflow('workflow', deps);
// API handler
export const POST = async (request: Request) => {
const body = await request.json();
const result = await workflow.run(async ({ step, deps }) => {
const input = await step('validatePost', () => deps.validatePost(body));
const post = await step('createPost', () => deps.createPost(input));
return post;
});
if (!result.ok) {
if (result.error.type === 'VALIDATION') {
return Response.json(
{ error: 'Validation failed', issues: result.error.issues },
{ status: 400 }
);
}
return Response.json({ error: 'Server error' }, { status: 500 });
}
return Response.json(result.value, { status: 201 });
};

Handle complex nested data:

const AddressSchema = z.object({
street: z.string(),
city: z.string(),
country: z.string(),
postalCode: z.string(),
});
const OrderSchema = z.object({
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive(),
})).min(1, 'Order must have at least one item'),
shippingAddress: AddressSchema,
billingAddress: AddressSchema.optional(),
});
// Validate with detailed error paths
const validateOrder = (data: unknown) => {
const result = zodToResult(OrderSchema, data);
if (!result.ok) {
// Issues include full paths like 'items.0.quantity' or 'shippingAddress.city'
console.log(result.error.issues.map(i => `${i.path.join('.')}: ${i.message}`));
}
return result;
};

Complete workflow combining Zod validation with database operations:

import { z } from 'zod';
import { ok, err, type Result, type AsyncResult } from 'awaitly';
import { createWorkflow } from 'awaitly/workflow';
// Schemas
const EmailSchema = z.string().email();
const PasswordSchema = z.string()
.min(8)
.regex(/[A-Z]/, 'Must contain uppercase')
.regex(/[a-z]/, 'Must contain lowercase')
.regex(/[0-9]/, 'Must contain number');
const RegisterSchema = z.object({
email: EmailSchema,
password: PasswordSchema,
name: z.string().min(2).max(100),
});
// Error types
type ValidationError = { type: 'VALIDATION'; issues: z.ZodIssue[] };
type EmailTakenError = { type: 'EMAIL_TAKEN'; email: string };
type DbError = { type: 'DB_ERROR'; message: string };
// Dependencies
const deps = {
validateInput: (raw: unknown) => {
const parsed = RegisterSchema.safeParse(raw);
return Promise.resolve(
parsed.success
? ok(parsed.data)
: err({ type: 'VALIDATION' as const, issues: parsed.error.issues })
);
},
checkEmailExists: async (email: string): AsyncResult<boolean, DbError> => {
// In real code, this would query the database
return ok(false);
},
createUser: async (data: { email: string; password: string; name: string }): AsyncResult<
{ id: string; email: string; name: string },
DbError
> => {
return ok({ id: '123', email: data.email, name: data.name });
},
};
// Registration workflow
const registerWorkflow = createWorkflow('workflow', deps);
const register = async (rawInput: unknown) => {
return registerWorkflow.run(async ({ step, deps }) => {
// Step 1: Validate input
const input = await step('validateInput', () => deps.validateInput(rawInput));
// Step 2: Check if email is taken
const emailExists = await step('checkEmailExists', () => deps.checkEmailExists(input.email));
if (emailExists) {
return await step('createUser', err({ type: 'EMAIL_TAKEN' as const, email: input.email }));
}
// Step 3: Create user
const user = await step('createUser', () => deps.createUser(input));
return user;
});
};
// Usage
const result = await register({
email: 'user@example.com',
password: 'SecurePass123',
name: 'Jane Doe',
});
if (!result.ok) {
switch (result.error.type) {
case 'VALIDATION':
console.log('Invalid input:', result.error.issues);
break;
case 'EMAIL_TAKEN':
console.log('Email already registered:', result.error.email);
break;
case 'DB_ERROR':
console.log('Database error:', result.error.message);
break;
}
}

Copy this utility file to your project:

src/lib/zod-result.ts
import { z } from 'zod';
import { ok, err, type Result } from 'awaitly';
export type ValidationError = {
type: 'VALIDATION';
issues: z.ZodIssue[];
};
/**
* Convert Zod safeParse to Result
*/
export const zodToResult = <T>(
schema: z.ZodSchema<T>,
data: unknown
): Result<T, ValidationError> => {
const parsed = schema.safeParse(data);
return parsed.success
? ok(parsed.data)
: err({ type: 'VALIDATION', issues: parsed.error.issues });
};
/**
* Async version for schemas with async refinements
*/
export const zodToResultAsync = async <T>(
schema: z.ZodSchema<T>,
data: unknown
): Promise<Result<T, ValidationError>> => {
const parsed = await schema.safeParseAsync(data);
return parsed.success
? ok(parsed.data)
: err({ type: 'VALIDATION', issues: parsed.error.issues });
};
/**
* Convert Zod issues to a field error map (useful for forms)
*/
export const issuesToFieldErrors = (issues: z.ZodIssue[]): Record<string, string> => {
return issues.reduce((acc, issue) => {
const field = issue.path.join('.') || '_root';
acc[field] = issue.message;
return acc;
}, {} as Record<string, string>);
};
/**
* Get the first error message (useful for simple error displays)
*/
export const getFirstError = (error: ValidationError): string => {
return error.issues[0]?.message ?? 'Validation failed';
};