---
name: forge-validation
description: Schema-first input validation at every trust boundary. zod / valibot / pydantic, derive-types-from-schema, structured field-level error responses, env validation at startup, file upload sniffing, size caps. Contains ready-to-paste schemas, type derivation, error-mapping helper, env validator. Use whenever data crosses a trust boundary - HTTP, queue, file upload, env, third-party API responses.
license: MIT
---

# forge-validation

You are writing code that accepts untrusted input. Default agent-written validation is "we check that email is a string in the handler, that should be enough." It is not. Validation belongs at every trust boundary, declared with a schema library, and returning structured errors that the client can act on. This skill exists to make that systematic.

The mental model: every byte from outside is hostile until validated. Validation is a typed gate. Once past the gate, types in the rest of the program are guarantees, not hopes.

## Quick reference (the things you must never ship)

1. `req.body.email` accessed without a prior schema parse.
2. `JSON.parse(input)` followed by direct field access.
3. Hand-rolled email/URL/UUID/credit-card regex.
4. `Record<string, any>` for "I don't know the shape."
5. Validation duplicated in 3 places "to be safe."
6. Schema and type declared separately and drifting.
7. Env vars accessed at first use instead of validated at startup.
8. `z.string()` with no `.max(...)` on a field a client controls.
9. File upload trusted on its `Content-Type` header without byte sniffing.
10. Third-party API response trusted without validation.

## Hard rules

### Where to validate

**1. At every trust boundary.** HTTP body, query string, message-queue payload, file upload, scraped data, third-party API response, env at process start, anything from a table written by a different system.

**2. Once per boundary, then trust the type.** Validation deep inside business logic is duplication and hides the boundary.

**3. Env vars validated at startup, not at first use.** Missing/malformed env should crash the process on boot, with a clear message.

### What to use

**4. Pick one schema library and use it everywhere.**
- TypeScript: **zod** (mature, biggest ecosystem), **valibot** (smaller bundle, faster), or **arktype** (TypeScript-native syntax). Pick one.
- Python: **pydantic v2**.
- Go: **go-playground/validator** with struct tags, or hand-rolled validators for fewer than 5 fields.
- Rust: **serde** with custom validators.
- Java/Kotlin: Jakarta Validation (Bean Validation 3.0).

**5. The schema IS the source of truth. Derive the type from it.**

```ts
import { z } from "zod";

const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email().max(320),
  name: z.string().min(1).max(200),
  created_at: z.string().datetime(),
});
type User = z.infer<typeof UserSchema>;
```

Never the other way around. Hand-written types that drift from the schema are how validation gaps appear.

### What to validate

**6. Type, format, range.** Email shape, UUID shape, ISO date, positive integer, length within bounds. The compiler does not know these.

**7. Closed value sets.** Use `enum` in the schema.

```ts
status: z.enum(["active", "archived", "banned"])
```

Untyped strings are how invalid states sneak in.

**8. Cross-field invariants.**

```ts
const DateRange = z.object({
  start: z.string().datetime(),
  end:   z.string().datetime(),
}).refine((v) => v.start <= v.end, {
  message: "start must be <= end",
  path: ["end"],
});
```

**9. Size limits on collections and strings.** No `z.string()` without `.max(...)`. No `z.array(...)` without a length cap. Resource-exhaustion attack class.

```ts
// BAD
notes: z.string()

// GOOD
notes: z.string().max(5000)

// BAD
items: z.array(LineItem)

// GOOD
items: z.array(LineItem).min(1).max(50)
```

**10. Coerce only intentionally.** Default to strict. `z.string().email()` not `z.coerce.string().email()`. Coercion is convenient and dangerous; opt in per field.

### What NOT to validate

**11. Do not validate twice.** Schema at the boundary covers it. Re-checking in the handler is duplication.

**12. Do not validate impossible cases.** A function that takes a `User` (already validated) does not check whether its email is a string. The type system handles it.

**13. Do not write hand-rolled regex for common formats.** Use `.email()`, `.uuid()`, `.url()`. Hand-rolled regexes for these are wrong roughly always.

### Error responses

**14. Validation errors return 400 with structured field details.** See forge-api-design rule 7 for the canonical shape.

```ts
function issuesToFields(error: z.ZodError) {
  return error.issues.map((i) => ({
    path: i.path.join("."),
    code: i.code,
    message: i.message,
  }));
}

const parsed = CreateOrderSchema.safeParse(body);
if (!parsed.success) {
  return errorResponse(c, 400, "validation_failed", "Validation failed for one or more fields.", {
    fields: issuesToFields(parsed.error),
  });
}
```

The client renders per-field errors next to inputs from `details.fields[].path`.

**15. Error codes are stable.** Once you ship `validation_failed`, do not rename it.

### Schemas as documentation

**16. Generate OpenAPI / JSON Schema from your schemas where possible.** `zod-to-openapi`, `pydantic.model_json_schema()`. One source of truth for runtime validation and API docs.

**17. Schemas live next to the route handler, not in a separate `schemas/` mausoleum.** A schema 200 lines away from the handler that uses it rots.

### Untrusted sources

**18. JSON.parse → schema parse, in one breath.**

```ts
// BAD
const user = JSON.parse(input) as User;

// GOOD
const user = UserSchema.parse(JSON.parse(input));

// SAFER (no throw on failure)
const parsed = UserSchema.safeParse(JSON.parse(input));
if (!parsed.success) return err(...);
const user = parsed.data;
```

**19. Third-party API responses get validated.** Yes, even "trusted" partners. The shape they document is the shape they intended; the shape they send is sometimes different.

**20. Database reads from a foreign-owned table get validated.** Different service writes; you do not control what shape arrives.

### File uploads

**21. Validate `Content-Type` at receipt, but trust nothing - sniff the bytes.**

```ts
import { fileTypeFromBuffer } from "file-type";

const detected = await fileTypeFromBuffer(buffer);
if (!detected || !["image/png", "image/jpeg", "image/webp"].includes(detected.mime)) {
  return errorResponse(c, 400, "unsupported_file_type", "Only PNG/JPEG/WebP accepted.");
}
```

**22. Cap upload size at proxy AND handler.** Nginx `client_max_body_size`, application body-parser limit. Defense in depth.

**23. Reject filenames with path separators, null bytes, or Windows reserved names if you serve on Windows.**

### Special types

**24. Dates are ISO 8601 on the wire, `Date` in code.** Schema converts at the boundary.

```ts
created_at: z.string().datetime().transform((s) => new Date(s))
```

**25. Money is a struct: amount in smallest unit (cents) + currency code.** Never a float.

```ts
const Money = z.object({
  amount_cents: z.number().int().nonnegative(),
  currency: z.enum(["USD", "EUR", "KZT"]),
});
```

**26. Phone numbers normalized to E.164 via libphonenumber.** Validation alone is insufficient.

## Common AI-output patterns to reject

| Pattern | Why wrong | Fix |
| --- | --- | --- |
| `req.body.email` direct access | No validation at boundary | `Schema.safeParse(req.body)` first |
| `JSON.parse(...)` + property access | Same | `Schema.parse(JSON.parse(...))` |
| `/^[a-zA-Z0-9._-]+@.../` for email | Wrong (every hand-rolled email regex is wrong) | `z.string().email()` |
| `z.string()` for user-typed text | No cap, DoS surface | `z.string().max(N)` |
| `Record<string, any>` | "JSON" with no shape | `z.record(z.unknown())` validated, then `Record<string, T>` |
| Schema in `schemas/` folder far from handler | Rots in isolation | Co-locate or import inline |
| Validation in handler AND repo AND DTO | Triple-check | Once at the boundary |
| `as User` cast on JSON.parse output | Lie to compiler | Validate, type-narrow naturally |
| Env vars read at first use with no validation | Crashes deep in handler, not on boot | Startup validator |
| File upload trusted by Content-Type header | Spoofable | `file-type` byte sniff |

## Worked example: env validation at startup

```ts
import { z } from "zod";

const EnvSchema = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
  PORT: z.coerce.number().int().positive().default(3000),
  DATABASE_URL: z.string().url(),
  STRIPE_API_KEY: z.string().startsWith("sk_"),
  SESSION_SECRET: z.string().min(32),
  LOG_LEVEL: z.enum(["trace", "debug", "info", "warn", "error"]).default("info"),
});

export type Env = z.infer<typeof EnvSchema>;

export const env: Env = (() => {
  const parsed = EnvSchema.safeParse(process.env);
  if (!parsed.success) {
    console.error(
      "[fatal] invalid environment\n" +
      parsed.error.issues.map((i) => `  ${i.path.join(".")}: ${i.message}`).join("\n"),
    );
    process.exit(1);
  }
  return parsed.data;
})();
```

What this does right: validates at startup (rule 3); typed `env` object the rest of the app uses; explicit `safeParse` + `process.exit(1)` on failure; clear list of which fields are wrong; no defaults for secrets (`SESSION_SECRET` is required).

## Worked example: HTTP endpoint with field-level errors

```ts
import { z } from "zod";

const CreateOrderBody = z.object({
  customer_id: z.string().uuid(),
  currency: z.enum(["USD", "EUR", "KZT"]),
  items: z
    .array(z.object({
      sku: z.string().min(1).max(64),
      quantity: z.number().int().positive().max(1000),
      unit_price_cents: z.number().int().nonnegative(),
    }))
    .min(1)
    .max(50),
});

app.post("/v1/orders", async (c) => {
  let body: unknown;
  try {
    body = await c.req.json();
  } catch {
    return errorResponse(c, 400, "invalid_json", "Request body is not valid JSON.");
  }

  const parsed = CreateOrderBody.safeParse(body);
  if (!parsed.success) {
    return errorResponse(c, 400, "validation_failed", "Validation failed for one or more fields.", {
      fields: parsed.error.issues.map((i) => ({
        path: i.path.join("."),
        code: i.code,
        message: i.message,
      })),
    });
  }
  const input = parsed.data;  // typed, narrowed, safe

  // ...
});
```

A client receives:

```json
{
  "error": {
    "code": "validation_failed",
    "message": "Validation failed for one or more fields.",
    "details": {
      "fields": [
        { "path": "currency",          "code": "invalid_enum_value", "message": "Invalid enum value." },
        { "path": "items.0.quantity",  "code": "too_small",          "message": "Number must be greater than 0" }
      ]
    },
    "request_id": "01HXY..."
  }
}
```

Now the client renders per-field error messages.

## Workflow

When adding a new endpoint or message handler:

1. **Write the schema first.** Before the handler.
2. **Derive types from the schema.** `z.infer<typeof S>`.
3. **The handler signature accepts the parsed type, not the raw input.**
4. **Parse at the entry point. If parsing fails, return structured error.**
5. **Inside the handler, treat the input as trusted.**
6. **Validate every external response you consume.** Other companies' APIs included.

## Verification

```bash
bash skills/backend/forge-validation/verify/check_validation.sh path/to/handler.ts
```

Flags: `JSON.parse` without schema in same file, `req.body.X` access without `Schema.parse(req.body)`, hand-rolled email regex.

## When to skip this skill

- Internal-only RPC between services that share types (still want validation, less critical).
- Static data shipped with the application.
- Test fixtures.

## Related skills

- [`forge-api-design`](../forge-api-design/SKILL.md) - canonical error shape, status codes.
- [`forge-error-handling`](../forge-error-handling/SKILL.md) - Result types, boundary catches.
- [`forge-secrets`](../../security/forge-secrets/SKILL.md) - env validation as a security boundary.
- [`forge-typescript`](../forge-typescript/SKILL.md) - `z.infer`, type narrowing.
