{"slug":"typescript-zod-validation","title":"TypeScript and Zod: Type Safety, Boundary Validation, and Schema Patterns","tags":["typescript","zod","validation","types","schema","boundary"],"agent_summary":"TypeScript production patterns with Zod — boundary validation at API inputs/outputs, schema composition, discriminated unions, branded types, transform pipelines, error formatting, and inference patterns for full-stack type safety.","trigger_phrases":["Zod validation","Zod schema","TypeScript Zod","boundary validation","API input validation","Zod parse","Zod safeParse","TypeScript types"],"runnable":false,"markdown":"\n## Overview\n\nValidate at every trust boundary — API inputs, environment variables, external API responses, form data. Zod provides runtime validation with TypeScript type inference. Never trust data from outside your process boundary.\n\n## Core Patterns\n\n### Basic Schema and Inference\n\n```typescript\nimport { z } from \"zod\";\n\nconst UserSchema = z.object({\n  id: z.string().uuid(),\n  email: z.string().email(),\n  name: z.string().min(1).max(100),\n  role: z.enum([\"admin\", \"user\", \"viewer\"]),\n  createdAt: z.string().datetime(),\n  metadata: z.record(z.string(), z.unknown()).optional(),\n});\n\n// Infer TypeScript type from schema\ntype User = z.infer<typeof UserSchema>;\n\n// Parse (throws on failure)\nconst user = UserSchema.parse(data);\n\n// Safe parse (returns result object)\nconst result = UserSchema.safeParse(data);\nif (!result.success) {\n  console.error(result.error.flatten());\n} else {\n  const user = result.data;\n}\n```\n\n### Schema Composition\n\n```typescript\n// Base schemas\nconst IdSchema = z.string().uuid();\nconst TimestampSchema = z.string().datetime();\n\nconst BaseEntitySchema = z.object({\n  id: IdSchema,\n  createdAt: TimestampSchema,\n  updatedAt: TimestampSchema,\n});\n\n// Extend base\nconst ProjectSchema = BaseEntitySchema.extend({\n  name: z.string().min(1).max(100),\n  status: z.enum([\"active\", \"archived\", \"deleted\"]),\n  userId: IdSchema,\n});\n\n// Create insert schema (omit server-generated fields)\nconst CreateProjectSchema = ProjectSchema.omit({ id: true, createdAt: true, updatedAt: true });\nconst UpdateProjectSchema = ProjectSchema.partial().required({ id: true });\n\ntype Project = z.infer<typeof ProjectSchema>;\ntype CreateProject = z.infer<typeof CreateProjectSchema>;\ntype UpdateProject = z.infer<typeof UpdateProjectSchema>;\n```\n\n### API Route Validation Pattern\n\n```typescript\n// Always validate at the API boundary\nexport async function POST(request: NextRequest) {\n  const body = await request.json().catch(() => null);\n\n  if (!body) {\n    return NextResponse.json({ error: \"Invalid JSON\" }, { status: 400 });\n  }\n\n  const result = CreateProjectSchema.safeParse(body);\n  if (!result.success) {\n    return NextResponse.json(\n      {\n        error: \"Validation failed\",\n        details: result.error.flatten().fieldErrors,\n      },\n      { status: 422 }\n    );\n  }\n\n  const project = await createProject(result.data);\n  return NextResponse.json({ project }, { status: 201 });\n}\n```\n\n### Environment Variable Validation\n\n```typescript\n// src/lib/env.ts — run at startup, fails fast on missing vars\nconst EnvSchema = z.object({\n  NODE_ENV: z.enum([\"development\", \"test\", \"production\"]),\n  DATABASE_URL: z.string().url(),\n  NEXT_PUBLIC_SUPABASE_URL: z.string().url(),\n  NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),\n  SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),\n  STRIPE_SECRET_KEY: z.string().startsWith(\"sk_\"),\n  STRIPE_WEBHOOK_SECRET: z.string().startsWith(\"whsec_\"),\n});\n\nexport const env = EnvSchema.parse(process.env);\n```\n\n## Advanced Patterns\n\n### Discriminated Unions\n\n```typescript\nconst ApiResponseSchema = z.discriminatedUnion(\"status\", [\n  z.object({\n    status: z.literal(\"success\"),\n    data: z.unknown(),\n  }),\n  z.object({\n    status: z.literal(\"error\"),\n    code: z.number(),\n    message: z.string(),\n  }),\n]);\n\ntype ApiResponse = z.infer<typeof ApiResponseSchema>;\n\nfunction handleResponse(raw: unknown): void {\n  const response = ApiResponseSchema.parse(raw);\n  if (response.status === \"success\") {\n    // TypeScript knows data is available here\n    processData(response.data);\n  } else {\n    // TypeScript knows code and message are available here\n    logError(response.code, response.message);\n  }\n}\n```\n\n### Transform Pipeline\n\n```typescript\nconst StripeAmountSchema = z\n  .number()\n  .int()\n  .positive()\n  .transform((cents) => ({\n    cents,\n    dollars: cents / 100,\n    formatted: new Intl.NumberFormat(\"en-US\", {\n      style: \"currency\",\n      currency: \"USD\",\n    }).format(cents / 100),\n  }));\n\n// StripeAmountSchema.parse(2900)\n// → { cents: 2900, dollars: 29, formatted: \"$29.00\" }\n```\n\n### Branded Types\n\n```typescript\n// Prevent confusion between IDs of different entity types\nconst UserIdSchema = z.string().uuid().brand(\"UserId\");\nconst ProjectIdSchema = z.string().uuid().brand(\"ProjectId\");\n\ntype UserId = z.infer<typeof UserIdSchema>;\ntype ProjectId = z.infer<typeof ProjectIdSchema>;\n\n// TypeScript error: can't pass ProjectId where UserId is expected\nfunction getUser(id: UserId): Promise<User> { /* ... */ }\nconst projectId = ProjectIdSchema.parse(\"123e4567-...\");\ngetUser(projectId);  // TS Error: Type 'ProjectId' is not assignable to 'UserId'\n```\n\n### Coercion for Query Params\n\n```typescript\n// URL params are always strings — coerce to the right type\nconst SearchParamsSchema = z.object({\n  page: z.coerce.number().int().positive().default(1),\n  limit: z.coerce.number().int().min(1).max(100).default(20),\n  status: z.enum([\"active\", \"archived\"]).optional(),\n  search: z.string().optional(),\n});\n\nexport async function GET(request: NextRequest) {\n  const params = Object.fromEntries(new URL(request.url).searchParams);\n  const { page, limit, status, search } = SearchParamsSchema.parse(params);\n  // page and limit are numbers, not strings\n}\n```\n\n### External API Response Validation\n\n```typescript\n// Always validate responses from external APIs\nconst GHLContactSchema = z.object({\n  id: z.string(),\n  email: z.string().email().optional(),\n  phone: z.string().optional(),\n  firstName: z.string().optional(),\n  lastName: z.string().optional(),\n});\n\nconst GHLContactListSchema = z.object({\n  contacts: z.array(GHLContactSchema),\n  meta: z.object({\n    total: z.number(),\n    count: z.number(),\n    currentPage: z.number(),\n  }),\n});\n\nasync function fetchContacts() {\n  const raw = await ghlClient.get(\"/contacts\");\n  const validated = GHLContactListSchema.parse(raw);\n  return validated;\n}\n```\n\n## Error Formatting\n\n```typescript\nconst result = UserSchema.safeParse(data);\nif (!result.success) {\n  // Field-level errors\n  const fieldErrors = result.error.flatten().fieldErrors;\n  // { email: [\"Invalid email\"], name: [\"Too short\"] }\n\n  // Flat list of errors\n  const issues = result.error.issues.map(issue => ({\n    path: issue.path.join(\".\"),\n    message: issue.message,\n  }));\n\n  // First error only\n  const firstError = result.error.issues[0]?.message;\n}\n```\n\n## TypeScript Strict Mode\n\nAlways enable in `tsconfig.json`:\n\n```json\n{\n  \"compilerOptions\": {\n    \"strict\": true,\n    \"noUncheckedIndexedAccess\": true,\n    \"noImplicitReturns\": true,\n    \"exactOptionalPropertyTypes\": true\n  }\n}\n```\n\n`strict: true` enables: `strictNullChecks`, `strictFunctionTypes`, `strictPropertyInitialization`, `noImplicitAny`.\n","html":"<h2>Overview</h2>\n<p>Validate at every trust boundary — API inputs, environment variables, external API responses, form data. Zod provides runtime validation with TypeScript type inference. Never trust data from outside your process boundary.</p>\n<h2>Core Patterns</h2>\n<h3>Basic Schema and Inference</h3>\n<pre><code class=\"language-typescript\">import { z } from \"zod\";\n\nconst UserSchema = z.object({\n  id: z.string().uuid(),\n  email: z.string().email(),\n  name: z.string().min(1).max(100),\n  role: z.enum([\"admin\", \"user\", \"viewer\"]),\n  createdAt: z.string().datetime(),\n  metadata: z.record(z.string(), z.unknown()).optional(),\n});\n\n// Infer TypeScript type from schema\ntype User = z.infer&#x3C;typeof UserSchema>;\n\n// Parse (throws on failure)\nconst user = UserSchema.parse(data);\n\n// Safe parse (returns result object)\nconst result = UserSchema.safeParse(data);\nif (!result.success) {\n  console.error(result.error.flatten());\n} else {\n  const user = result.data;\n}\n</code></pre>\n<h3>Schema Composition</h3>\n<pre><code class=\"language-typescript\">// Base schemas\nconst IdSchema = z.string().uuid();\nconst TimestampSchema = z.string().datetime();\n\nconst BaseEntitySchema = z.object({\n  id: IdSchema,\n  createdAt: TimestampSchema,\n  updatedAt: TimestampSchema,\n});\n\n// Extend base\nconst ProjectSchema = BaseEntitySchema.extend({\n  name: z.string().min(1).max(100),\n  status: z.enum([\"active\", \"archived\", \"deleted\"]),\n  userId: IdSchema,\n});\n\n// Create insert schema (omit server-generated fields)\nconst CreateProjectSchema = ProjectSchema.omit({ id: true, createdAt: true, updatedAt: true });\nconst UpdateProjectSchema = ProjectSchema.partial().required({ id: true });\n\ntype Project = z.infer&#x3C;typeof ProjectSchema>;\ntype CreateProject = z.infer&#x3C;typeof CreateProjectSchema>;\ntype UpdateProject = z.infer&#x3C;typeof UpdateProjectSchema>;\n</code></pre>\n<h3>API Route Validation Pattern</h3>\n<pre><code class=\"language-typescript\">// Always validate at the API boundary\nexport async function POST(request: NextRequest) {\n  const body = await request.json().catch(() => null);\n\n  if (!body) {\n    return NextResponse.json({ error: \"Invalid JSON\" }, { status: 400 });\n  }\n\n  const result = CreateProjectSchema.safeParse(body);\n  if (!result.success) {\n    return NextResponse.json(\n      {\n        error: \"Validation failed\",\n        details: result.error.flatten().fieldErrors,\n      },\n      { status: 422 }\n    );\n  }\n\n  const project = await createProject(result.data);\n  return NextResponse.json({ project }, { status: 201 });\n}\n</code></pre>\n<h3>Environment Variable Validation</h3>\n<pre><code class=\"language-typescript\">// src/lib/env.ts — run at startup, fails fast on missing vars\nconst EnvSchema = z.object({\n  NODE_ENV: z.enum([\"development\", \"test\", \"production\"]),\n  DATABASE_URL: z.string().url(),\n  NEXT_PUBLIC_SUPABASE_URL: z.string().url(),\n  NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),\n  SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),\n  STRIPE_SECRET_KEY: z.string().startsWith(\"sk_\"),\n  STRIPE_WEBHOOK_SECRET: z.string().startsWith(\"whsec_\"),\n});\n\nexport const env = EnvSchema.parse(process.env);\n</code></pre>\n<h2>Advanced Patterns</h2>\n<h3>Discriminated Unions</h3>\n<pre><code class=\"language-typescript\">const ApiResponseSchema = z.discriminatedUnion(\"status\", [\n  z.object({\n    status: z.literal(\"success\"),\n    data: z.unknown(),\n  }),\n  z.object({\n    status: z.literal(\"error\"),\n    code: z.number(),\n    message: z.string(),\n  }),\n]);\n\ntype ApiResponse = z.infer&#x3C;typeof ApiResponseSchema>;\n\nfunction handleResponse(raw: unknown): void {\n  const response = ApiResponseSchema.parse(raw);\n  if (response.status === \"success\") {\n    // TypeScript knows data is available here\n    processData(response.data);\n  } else {\n    // TypeScript knows code and message are available here\n    logError(response.code, response.message);\n  }\n}\n</code></pre>\n<h3>Transform Pipeline</h3>\n<pre><code class=\"language-typescript\">const StripeAmountSchema = z\n  .number()\n  .int()\n  .positive()\n  .transform((cents) => ({\n    cents,\n    dollars: cents / 100,\n    formatted: new Intl.NumberFormat(\"en-US\", {\n      style: \"currency\",\n      currency: \"USD\",\n    }).format(cents / 100),\n  }));\n\n// StripeAmountSchema.parse(2900)\n// → { cents: 2900, dollars: 29, formatted: \"$29.00\" }\n</code></pre>\n<h3>Branded Types</h3>\n<pre><code class=\"language-typescript\">// Prevent confusion between IDs of different entity types\nconst UserIdSchema = z.string().uuid().brand(\"UserId\");\nconst ProjectIdSchema = z.string().uuid().brand(\"ProjectId\");\n\ntype UserId = z.infer&#x3C;typeof UserIdSchema>;\ntype ProjectId = z.infer&#x3C;typeof ProjectIdSchema>;\n\n// TypeScript error: can't pass ProjectId where UserId is expected\nfunction getUser(id: UserId): Promise&#x3C;User> { /* ... */ }\nconst projectId = ProjectIdSchema.parse(\"123e4567-...\");\ngetUser(projectId);  // TS Error: Type 'ProjectId' is not assignable to 'UserId'\n</code></pre>\n<h3>Coercion for Query Params</h3>\n<pre><code class=\"language-typescript\">// URL params are always strings — coerce to the right type\nconst SearchParamsSchema = z.object({\n  page: z.coerce.number().int().positive().default(1),\n  limit: z.coerce.number().int().min(1).max(100).default(20),\n  status: z.enum([\"active\", \"archived\"]).optional(),\n  search: z.string().optional(),\n});\n\nexport async function GET(request: NextRequest) {\n  const params = Object.fromEntries(new URL(request.url).searchParams);\n  const { page, limit, status, search } = SearchParamsSchema.parse(params);\n  // page and limit are numbers, not strings\n}\n</code></pre>\n<h3>External API Response Validation</h3>\n<pre><code class=\"language-typescript\">// Always validate responses from external APIs\nconst GHLContactSchema = z.object({\n  id: z.string(),\n  email: z.string().email().optional(),\n  phone: z.string().optional(),\n  firstName: z.string().optional(),\n  lastName: z.string().optional(),\n});\n\nconst GHLContactListSchema = z.object({\n  contacts: z.array(GHLContactSchema),\n  meta: z.object({\n    total: z.number(),\n    count: z.number(),\n    currentPage: z.number(),\n  }),\n});\n\nasync function fetchContacts() {\n  const raw = await ghlClient.get(\"/contacts\");\n  const validated = GHLContactListSchema.parse(raw);\n  return validated;\n}\n</code></pre>\n<h2>Error Formatting</h2>\n<pre><code class=\"language-typescript\">const result = UserSchema.safeParse(data);\nif (!result.success) {\n  // Field-level errors\n  const fieldErrors = result.error.flatten().fieldErrors;\n  // { email: [\"Invalid email\"], name: [\"Too short\"] }\n\n  // Flat list of errors\n  const issues = result.error.issues.map(issue => ({\n    path: issue.path.join(\".\"),\n    message: issue.message,\n  }));\n\n  // First error only\n  const firstError = result.error.issues[0]?.message;\n}\n</code></pre>\n<h2>TypeScript Strict Mode</h2>\n<p>Always enable in <code>tsconfig.json</code>:</p>\n<pre><code class=\"language-json\">{\n  \"compilerOptions\": {\n    \"strict\": true,\n    \"noUncheckedIndexedAccess\": true,\n    \"noImplicitReturns\": true,\n    \"exactOptionalPropertyTypes\": true\n  }\n}\n</code></pre>\n<p><code>strict: true</code> enables: <code>strictNullChecks</code>, <code>strictFunctionTypes</code>, <code>strictPropertyInitialization</code>, <code>noImplicitAny</code>.</p>\n"}