{"componentChunkName":"component---src-gatsby-theme-chronoblog-templates-note-js","path":"/notes/zod-validation-express/","result":{"data":{"mdx":{"parent":{"__typename":"File","fields":{"gitLogLatestDate":"2026-07-22 00:11:03 +0200"}},"id":"e78c42da-22f4-5c8a-94ec-705496f27395","excerpt":"Express does not validate request input for you. Without a check at the edge, handlers get raw  req.body ,  req.query , and  req.params…","frontmatter":{"title":"Request validation with Zod in Express","date":"2026-07-21 00:45:00 UTC","job_ad":null,"job_ad_id":null,"job_ad_url":null,"tags":["zod","express","validation","node","typescript"],"cover":{"childImageSharp":{"fluid":{"base64":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAKCAYAAAC0VX7mAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAvUlEQVQoz62S627DIAxGef/3XJP0BiTcwZypTJr2Y+naaJY+IcvofAZb8Wb03r/P36ReJ/ED9AS4V/hLe66Kg9G7UGvDuYa1hW1tiIDSOrPaRsmC2xrGlCHvhJwFayvWVIIXUoqUXDG64n0audGZeVq5XfP4CjVPjvMSCKFyu0am08bpY+Vh5F0ZtccdawrWapwLLHMcwJwb+l4GVN8zKcrxJ4tU3Ja4nCPL/NVUDPLfQ+E94Etrc2zC+8v9CdF0FaV5PSD1AAAAAElFTkSuQmCC","aspectRatio":2.0869565217391304,"src":"/static/173122adc9e7ef3ce14b13ef43c38a1e/c4ecb/cover.png","srcSet":"/static/173122adc9e7ef3ce14b13ef43c38a1e/57ab0/cover.png 192w,\n/static/173122adc9e7ef3ce14b13ef43c38a1e/f4739/cover.png 384w,\n/static/173122adc9e7ef3ce14b13ef43c38a1e/c4ecb/cover.png 768w","srcWebp":"/static/173122adc9e7ef3ce14b13ef43c38a1e/dd090/cover.webp","srcSetWebp":"/static/173122adc9e7ef3ce14b13ef43c38a1e/ae504/cover.webp 192w,\n/static/173122adc9e7ef3ce14b13ef43c38a1e/fef30/cover.webp 384w,\n/static/173122adc9e7ef3ce14b13ef43c38a1e/dd090/cover.webp 768w","sizes":"(max-width: 768px) 100vw, 768px","presentationWidth":768,"presentationHeight":366},"resize":{"src":"/static/173122adc9e7ef3ce14b13ef43c38a1e/c4ecb/cover.png"}}}},"fields":{"slug":"/notes/zod-validation-express/","readingTime":{"text":"4 min read"}},"body":"function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }\n\nfunction _objectWithoutProperties(source, excluded) { if (source == null) return {}; var target = _objectWithoutPropertiesLoose(source, excluded); var key, i; if (Object.getOwnPropertySymbols) { var sourceSymbolKeys = Object.getOwnPropertySymbols(source); for (i = 0; i < sourceSymbolKeys.length; i++) { key = sourceSymbolKeys[i]; if (excluded.indexOf(key) >= 0) continue; if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue; target[key] = source[key]; } } return target; }\n\nfunction _objectWithoutPropertiesLoose(source, excluded) { if (source == null) return {}; var target = {}; var sourceKeys = Object.keys(source); var key, i; for (i = 0; i < sourceKeys.length; i++) { key = sourceKeys[i]; if (excluded.indexOf(key) >= 0) continue; target[key] = source[key]; } return target; }\n\n/* @jsxRuntime classic */\n\n/* @jsx mdx */\nvar _frontmatter = {\n  \"title\": \"Request validation with Zod in Express\",\n  \"date\": \"2026-07-21 00:45:00 UTC\",\n  \"cover\": \"./cover.png\",\n  \"tags\": [\"zod\", \"express\", \"validation\", \"node\", \"typescript\"],\n  \"canonical_url\": \"https://sevic.dev/notes/zod-validation-express/\"\n};\nvar layoutProps = {\n  _frontmatter: _frontmatter\n};\nvar MDXLayout = \"wrapper\";\nreturn function MDXContent(_ref) {\n  var components = _ref.components,\n      props = _objectWithoutProperties(_ref, [\"components\"]);\n\n  return mdx(MDXLayout, _extends({}, layoutProps, props, {\n    components: components,\n    mdxType: \"MDXLayout\"\n  }), mdx(\"p\", null, \"Express does not validate request input for you. Without a check at the edge, handlers get raw \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"req.body\"), \", \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"req.query\"), \", and \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"req.params\"), \" - strings where you expected numbers, missing fields, and shapes that only blow up deep in business logic.\"), mdx(\"p\", null, mdx(\"a\", _extends({\n    parentName: \"p\"\n  }, {\n    \"href\": \"https://zod.dev/\"\n  }), \"Zod\"), \" is a TypeScript-first schema library. You declare the shape once, infer types with \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"z.infer\"), \", and parse at the HTTP boundary so route handlers only see valid data. Invalid input becomes HTTP 400 before your code runs.\"), mdx(\"p\", null, \"This post covers Zod 4 schemas (\", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"z.email()\"), \", \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"z.uuid()\"), \", \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"z.coerce\"), \"), Express validation middleware, error formatting and pitfalls.\"), mdx(\"h3\", {\n    \"id\": \"prerequisites\"\n  }, \"Prerequisites\"), mdx(\"ul\", null, mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"Node.js version 26\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"Zod 4: \", mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \"npm i zod\")), mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"Express: \", mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \"npm i express\"), \" and \", mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \"npm i -D @types/express\"))), mdx(\"p\", null, \"Zod 3 method forms like \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"z.string().email()\"), \" still work but are deprecated in v4. Prefer the top-level APIs below.\"), mdx(\"h3\", {\n    \"id\": \"schemas\"\n  }, \"Schemas\"), mdx(\"pre\", null, mdx(\"code\", _extends({\n    parentName: \"pre\"\n  }, {\n    \"className\": \"language-ts\"\n  }), \"// schemas.ts\\nimport { z } from 'zod';\\n\\nexport const createUserSchema = z.object({\\n  email: z.email(),\\n  name: z.string().min(1).max(100),\\n  age: z.number().int().min(0).max(150).optional()\\n});\\n\\nexport type CreateUserInput = z.infer<typeof createUserSchema>;\\n\\nexport const userIdParamSchema = z.object({\\n  id: z.uuid()\\n});\\n\\nexport const listUsersQuerySchema = z.object({\\n  limit: z.coerce.number().int().min(1).max(100).default(10),\\n  q: z.string().trim().min(1).optional()\\n});\\n\")), mdx(\"p\", null, mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"z.coerce.number()\"), \" is useful for query strings - HTTP query values arrive as strings. Prefer \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"safeParse\"), \" over \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"parse\"), \" at the edge so you control the HTTP status and response body.\"), mdx(\"h3\", {\n    \"id\": \"format-errors-once\"\n  }, \"Format errors once\"), mdx(\"p\", null, \"Map \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"ZodError.issues\"), \" into a stable JSON body, or use Zod 4 helpers \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"z.flattenError()\"), \" / \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"z.treeifyError()\"), \" when you want field-keyed or nested shapes.\"), mdx(\"pre\", null, mdx(\"code\", _extends({\n    parentName: \"pre\"\n  }, {\n    \"className\": \"language-ts\"\n  }), \"// format-zod-error.ts\\nimport { ZodError } from 'zod';\\n\\nexport function formatZodError(error: ZodError) {\\n  return {\\n    message: 'Validation failed',\\n    issues: error.issues.map((issue) => ({\\n      path: issue.path.join('.') || '(root)',\\n      message: issue.message,\\n      code: issue.code\\n    }))\\n  };\\n}\\n\")), mdx(\"h3\", {\n    \"id\": \"validation-middleware\"\n  }, \"Validation middleware\"), mdx(\"p\", null, \"Validate \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"body\"), \", \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"query\"), \", and \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"params\"), \" before the route handler. Write parsed data back so handlers receive typed, coerced values.\"), mdx(\"pre\", null, mdx(\"code\", _extends({\n    parentName: \"pre\"\n  }, {\n    \"className\": \"language-ts\"\n  }), \"// validate.ts\\nimport { NextFunction, Request, Response } from 'express';\\nimport { ZodType } from 'zod';\\nimport { formatZodError } from './format-zod-error';\\n\\ntype RequestSchemas = {\\n  body?: ZodType;\\n  query?: ZodType;\\n  params?: ZodType;\\n};\\n\\nexport function validate(schemas: RequestSchemas) {\\n  return (req: Request, res: Response, next: NextFunction) => {\\n    const parseOrReject = (schema: ZodType, value: unknown) => {\\n      const parsed = schema.safeParse(value);\\n      if (!parsed.success) {\\n        res.status(400).json(formatZodError(parsed.error));\\n        return null;\\n      }\\n      return parsed.data;\\n    };\\n\\n    if (schemas.body) {\\n      const body = parseOrReject(schemas.body, req.body);\\n      if (body === null) return;\\n      req.body = body;\\n    }\\n\\n    if (schemas.query) {\\n      const query = parseOrReject(schemas.query, req.query);\\n      if (query === null) return;\\n      res.locals.query = query;\\n    }\\n\\n    if (schemas.params) {\\n      const params = parseOrReject(schemas.params, req.params);\\n      if (params === null) return;\\n      res.locals.params = params;\\n    }\\n\\n    next();\\n  };\\n}\\n\")), mdx(\"p\", null, \"Wire it per route:\"), mdx(\"pre\", null, mdx(\"code\", _extends({\n    parentName: \"pre\"\n  }, {\n    \"className\": \"language-ts\"\n  }), \"app.post('/users', validate({ body: createUserSchema }), (req, res) => {\\n  // req.body is CreateUserInput\\n  res.status(201).json({ id: crypto.randomUUID(), ...req.body });\\n});\\n\\napp.get('/users', validate({ query: listUsersQuerySchema }), (req, res) => {\\n  const { limit, q } = res.locals.query;\\n  // ...\\n});\\n\\napp.get('/users/:id', validate({ params: userIdParamSchema }), (req, res) => {\\n  const { id } = res.locals.params;\\n  // ...\\n});\\n\")), mdx(\"p\", null, \"Query and params are stored on \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"res.locals\"), \" because Express types treat \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"req.query\"), \" / \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"req.params\"), \" as string maps; replacing them with coerced objects fights the type system.\"), mdx(\"h3\", {\n    \"id\": \"pitfalls\"\n  }, \"Pitfalls\"), mdx(\"ul\", null, mdx(\"li\", {\n    parentName: \"ul\"\n  }, mdx(\"strong\", {\n    parentName: \"li\"\n  }, \"Query strings are strings\"), \" - use \", mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \"z.coerce\"), \" (or \", mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \"z.string()\"), \" + transform) for numbers and booleans.\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, mdx(\"strong\", {\n    parentName: \"li\"\n  }, mdx(\"inlineCode\", {\n    parentName: \"strong\"\n  }, \"parse\"), \" vs \", mdx(\"inlineCode\", {\n    parentName: \"strong\"\n  }, \"safeParse\")), \" - \", mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \"parse\"), \" throws a raw \", mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \"ZodError\"), \"; map it to HTTP 400 yourself or stick to \", mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \"safeParse\"), \".\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, mdx(\"strong\", {\n    parentName: \"li\"\n  }, \"Strip unknown keys\"), \" - Zod object schemas strip unknown keys by default. Use \", mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \".strict()\"), \" if you want to reject them.\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, mdx(\"strong\", {\n    parentName: \"li\"\n  }, \"Do not trust types alone\"), \" - \", mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \"CreateUserInput\"), \" is compile-time only; always parse at the boundary.\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, mdx(\"strong\", {\n    parentName: \"li\"\n  }, \"Stricter UUIDs in Zod 4\"), \" - \", mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \"z.uuid()\"), \" follows RFC 9562/4122 more strictly; use \", mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \"z.guid()\"), \" for a looser 8-4-4-4-12 hex pattern.\")));\n}\n;\nMDXContent.isMDXComponent = true;"}},"pageContext":{"id":"e78c42da-22f4-5c8a-94ec-705496f27395"}},"staticQueryHashes":["1961101537","2542493696"]}