How to Validate Form Data in Express.js and FastAPI
Practical patterns for validating form and API request data — combining schema validation (Zod / Pydantic) with semantic identifier validation (IsValid API) using parallel requests. Includes reusable middleware for Express.js and dependency injection for FastAPI.
Contents
1. Two-phase validation approach
Effective form validation uses two sequential phases — fail fast at the schema level before making any API calls:
- • Required fields present
- • Correct types
- • Length / pattern constraints
- • Enum values
- • Checksum verification
- • Registry lookup
- • Cross-field consistency
- • Domain-specific rules
2. Express.js — payment form validation
A middleware that validates IBAN + BIC with Zod for format and the IsValid SDK for semantics. The route handler only runs when both phases pass.
import { z } from 'zod' import { createClient } from '@isvalid-dev/sdk' const iv = createClient({ apiKey: process.env.ISVALID_API_KEY! }) // 1. Format schema (Zod) const PaymentSchema = z.object({ iban: z.string().min(15).max(34), bic: z.string().min(8).max(11), amount: z.number().positive(), currency: z.enum(['EUR', 'GBP', 'USD']), }) // 2. Semantic validation (IsValid API) async function validatePaymentSemantics(data: z.infer<typeof PaymentSchema>) { const errors: Record<string, string> = {} const [ibanR, bicR] = await Promise.all([ iv.iban(data.iban).catch(() => ({ valid: false, isSEPA: false })), iv.bic(data.bic).catch(() => ({ valid: false })), ]) if (!ibanR.valid) errors.iban = 'Invalid IBAN — checksum failed' if (!ibanR.isSEPA) errors.iban = 'IBAN is not SEPA-eligible' if (!bicR.valid) errors.bic = 'Invalid BIC code' return errors } // 3. Middleware export async function validatePayment(req, res, next) { const parsed = PaymentSchema.safeParse(req.body) if (!parsed.success) { return res.status(422).json({ title: 'Validation Failed', status: 422, errors: parsed.error.flatten().fieldErrors, }) } const semanticErrors = await validatePaymentSemantics(parsed.data) if (Object.keys(semanticErrors).length > 0) { return res.status(422).json({ title: 'Validation Failed', status: 422, errors: semanticErrors, }) } req.validatedBody = parsed.data next() } // 4. Route — only runs with clean data app.post('/payments', validatePayment, async (req, res) => { const payment = req.validatedBody // ... process payment res.status(201).json({ id: newPayment.id }) })
3. Express.js — registration form validation
An adaptive pattern that conditionally validates the VAT number only when provided, running all checks in parallel with Promise.allSettled.
import { z } from 'zod' const RegistrationSchema = z.object({ email: z.string().email(), phone: z.string().min(7).max(20), vatNumber: z.string().optional(), }) async function validateRegistration(req, res, next) { const parsed = RegistrationSchema.safeParse(req.body) if (!parsed.success) { return res.status(422).json({ title: 'Validation Failed', status: 422, errors: parsed.error.flatten().fieldErrors, }) } const { email, phone, vatNumber } = parsed.data const tasks: Promise<unknown>[] = [iv.email(email), iv.phone(phone)] const keys = ['email', 'phone'] if (vatNumber) { tasks.push(iv.vat(vatNumber)); keys.push('vatNumber') } const results = await Promise.allSettled(tasks) const errors: Record<string, string> = {} results.forEach((r, i) => { if (r.status === 'fulfilled' && !(r.value as { valid: boolean }).valid) { errors[keys[i]] = `Invalid ${keys[i]}` } }) if (Object.keys(errors).length > 0) { return res.status(422).json({ title: 'Validation Failed', status: 422, errors }) } req.validatedBody = parsed.data next() }
4. FastAPI — payment form validation
FastAPI's dependency injection system makes it clean to separate Pydantic format validation from async semantic validation — the route handler receives only validated data.
from fastapi import FastAPI, Depends, HTTPException from pydantic import BaseModel, field_validator from isvalid_sdk import IsValidConfig, create_client from typing import Optional import asyncio app = FastAPI() iv = create_client(IsValidConfig(api_key="YOUR_API_KEY")) # 1. Pydantic model (format validation) class PaymentRequest(BaseModel): iban: str bic: str amount: float currency: str @field_validator('amount') @classmethod def amount_must_be_positive(cls, v): if v <= 0: raise ValueError('Amount must be positive') return v @field_validator('currency') @classmethod def currency_must_be_valid(cls, v): if v not in ('EUR', 'GBP', 'USD'): raise ValueError('Unsupported currency') return v # 2. Semantic validation dependency async def validate_payment_semantics(data: PaymentRequest) -> PaymentRequest: iban_r, bic_r = await asyncio.gather(iv.iban(data.iban), iv.bic(data.bic)) errors = {} if not iban_r.valid: errors["iban"] = "Invalid IBAN — checksum failed" if not iban_r.is_sepa: errors["iban"] = "IBAN is not SEPA-eligible" if not bic_r.valid: errors["bic"] = "Invalid BIC code" if errors: raise HTTPException(status_code=422, detail={"errors": errors}) return data # 3. Route with dependency injection @app.post("/payments", status_code=201) async def create_payment(data: PaymentRequest = Depends(validate_payment_semantics)): # data is clean — both format and semantic validation passed return {"status": "created"}
5. Consistent error response format
Both Express and FastAPI patterns return the same field-level error structure — easy to consume in any frontend:
HTTP/1.1 422 Unprocessable Content Content-Type: application/json { "title": "Validation Failed", "status": 422, "errors": { "iban": "IBAN is not SEPA-eligible", "bic": "Invalid BIC code" } }
Consuming errors in React
const res = await fetch('/payments', { method: 'POST', body: JSON.stringify(form) }) if (res.status === 422) { const { errors } = await res.json() setFieldErrors(errors) // { iban: "...", bic: "..." } → display next to each field }
6. Checklist
Related guides
One API for all your validation needs
IBAN, VAT, credit card, GSTIN, phone, email, postal code, and 40+ more — one SDK, one key.
Get your API key →