# Data Validation Schema Patterns

> Use when reviewing code for data validation correctness — covers validation boundary principle, TypeScript (Zod, Joi, io-ts), Python (Pydantic v2, marshmallow, dataclasses), Go (go-playground/validator), Java (Bean Validation / Hibernate Validator), schema evolution and compatibility, coercion vs strict parsing, custom validators, composable schemas, and validation anti-patterns with red flags an…

- **Type:** Skill
- **Install:** `agentstack add skill-mickeyyaya-refactoring-skills-data-validation-schema-patterns`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [mickeyyaya](https://agentstack.voostack.com/s/mickeyyaya)
- **Installs:** 0
- **Category:** [Agent Skills](https://agentstack.voostack.com/c/agent-skills)
- **Latest version:** 0.1.0
- **License:** MIT
- **Upstream author:** [mickeyyaya](https://github.com/mickeyyaya)
- **Source:** https://github.com/mickeyyaya/refactoring-skills/tree/main/skills/data-validation-schema-patterns

## Install

```sh
agentstack add skill-mickeyyaya-refactoring-skills-data-validation-schema-patterns
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## About

# Data Validation and Schema Patterns for Code Review

## Overview

Unvalidated input is the root cause of injection attacks, data corruption, and unexpected crashes. Validation is not a one-time gate at the UI layer — every system boundary must enforce its own schema contract. Use this guide during code review to catch validation hazards before they ship.

**When to use:** Reviewing code that accepts external data (HTTP requests, queue messages, file uploads, CLI args, environment variables); designing API contracts; evaluating schema evolution for backward compatibility; auditing trust boundaries in internal services.

## Quick Reference

| Pattern | Core Idea | Primary Red Flag |
|---------|-----------|-----------------|
| Validation Boundary | Validate at every trust boundary, not just at the UI | Passing raw `unknown` / `any` deep into business logic |
| TypeScript / Zod | Runtime schema tied to compile-time type | `z.any()` escapes, skipping `.parse()` on external data |
| TypeScript / Joi | Rich rule DSL with detailed error messages | `.unknown(true)` without explicit allow-list |
| TypeScript / io-ts | Codec = decoder + encoder, composable | Ignoring `left` branch of `Either` decode result |
| Python / Pydantic v2 | Model-first validation, high performance | `model_config = {'arbitrary_types_allowed': True}` masking issues |
| Python / marshmallow | Schema-centric, explicit serialization control | `load()` result used without checking validation errors |
| Python / dataclasses | Structural typing only, no runtime enforcement | Trusting `@dataclass` fields have the declared type at runtime |
| Go / validator | Struct tag-based declarative rules | Missing `binding:"required"` tags on mandatory fields |
| Java / Bean Validation | Annotation-driven, integrates with frameworks | `@Valid` missing on nested objects or method parameters |
| Schema Evolution | Additive changes are safe; removals and renames break | Removing required fields without a deprecation cycle |
| Strict vs Coercive | Coercion silently accepts wrong types; strict fails fast | `z.coerce.number()` accepting `"abc"` → `NaN` |
| Custom Validators | Encode business rules as first-class schema constraints | Business rule checks scattered outside schema definition |

---

## Patterns in Detail

### 1. Validation Boundary Principle

**Core Rule:** Every system boundary — HTTP endpoint, queue consumer, file parser, CLI argument, environment variable, IPC call, or internal service API — is a trust boundary. Data that crosses a trust boundary must be validated before it is used.

**Red Flags:**
- `unknown` or `any` typed parameters passed directly to repository or service functions
- Validation only at the presentation layer; business logic assumes clean data
- Internal microservice calls skipping validation because "we control both sides"
- A single validation at ingestion time, then raw structs passed across multiple layers

**The Trust Boundary Map:**
```
External World
  → HTTP Request body        ← VALIDATE HERE
  → Query/path parameters    ← VALIDATE HERE
  → Message queue payload    ← VALIDATE HERE
  → File upload content      ← VALIDATE HERE
  → Env variables at startup ← VALIDATE HERE
  Internal Services
    → Inter-service HTTP      ← VALIDATE HERE (defense-in-depth)
    → DB query results        ← VALIDATE HERE (schema drift)
```

**TypeScript — boundary enforcement with Zod:**
```typescript
// WRONG — accepts unknown and passes it through
async function createUser(body: unknown) {
  return userRepo.insert(body as User); // any shape can reach the DB
}

// CORRECT — validate at the HTTP boundary, then work with typed data
const CreateUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1).max(100),
  role: z.enum(['admin', 'viewer']),
});

async function createUser(body: unknown): Promise {
  const input = CreateUserSchema.parse(body); // throws ZodError on failure
  return userRepo.insert(input);              // input is typed CreateUser
}
```

---

### 2. TypeScript Validation — Zod, Joi, io-ts

#### Zod

**Red Flags:**
- `z.any()` or `z.unknown()` used as a shortcut instead of a proper schema
- `.safeParse()` result used without checking `.success`
- Schemas defined per-request instead of at module level (performance)
- Missing `.strict()` — extra fields pass through silently

**Pattern — schema as the single source of truth:**
```typescript
import { z } from 'zod';

// Define once; derive the TypeScript type from it
const OrderSchema = z.object({
  id: z.string().uuid(),
  items: z.array(
    z.object({
      sku: z.string().min(1),
      qty: z.number().int().positive(),
      priceUsd: z.number().nonnegative(),
    })
  ).min(1),
  shippingAddress: z.object({
    street: z.string(),
    city: z.string(),
    postalCode: z.string().regex(/^\d{5}$/),
  }),
}).strict(); // reject unknown keys

export type Order = z.infer; // no duplication

// Safe parse — return Result instead of throwing
function parseOrder(raw: unknown): { ok: true; order: Order } | { ok: false; errors: string[] } {
  const result = OrderSchema.safeParse(raw);
  if (!result.success) {
    return { ok: false, errors: result.error.errors.map(e => `${e.path.join('.')}: ${e.message}`) };
  }
  return { ok: true, order: result.data };
}
```

#### Joi

**Red Flags:**
- `.unknown(true)` on root schema — any extra key is silently passed
- `abortEarly: true` (default) — stops at first error; clients see one error at a time
- No `.label()` on fields — error messages reference internal key names

```typescript
import Joi from 'joi';

const schema = Joi.object({
  email: Joi.string().email().required().label('Email'),
  age: Joi.number().integer().min(18).max(120).required().label('Age'),
}).options({ allowUnknown: false, abortEarly: false }); // collect all errors

const { error, value } = schema.validate(input);
if (error) {
  const messages = error.details.map(d => d.message);
  throw new ValidationError(messages);
}
```

#### io-ts

**Red Flags:**
- Ignoring the `Left` branch (validation failure) of `Either`
- Using `t.any` or `t.unknown` in codec definitions
- Decoding without the `PathReporter` for human-readable errors

```typescript
import * as t from 'io-ts';
import { PathReporter } from 'io-ts/PathReporter';
import { isLeft } from 'fp-ts/Either';

const UserCodec = t.type({
  id: t.string,
  email: t.string,
  role: t.union([t.literal('admin'), t.literal('viewer')]),
});
export type User = t.TypeOf;

function decodeUser(raw: unknown): User {
  const result = UserCodec.decode(raw);
  if (isLeft(result)) {
    throw new ValidationError(PathReporter.report(result).join('; '));
  }
  return result.right;
}
```

---

### 3. Python Validation — Pydantic v2, marshmallow, dataclasses

#### Pydantic v2

**Red Flags:**
- `model_config = ConfigDict(arbitrary_types_allowed=True)` used to suppress type errors
- `model.model_dump()` then re-instantiating a model — unnecessary round-trip
- Missing `@field_validator` for cross-field constraints
- `model_validate(data)` called with trusted-but-unverified internal dicts

```python
from pydantic import BaseModel, EmailStr, field_validator, model_validator
from typing import Literal

class OrderItem(BaseModel):
    sku: str
    qty: int
    price_usd: float

    @field_validator('qty')
    @classmethod
    def qty_must_be_positive(cls, v: int) -> int:
        if v  'Order':
        if not self.items:
            raise ValueError('order must have at least one item')
        return self

# Validate at boundary — raises ValidationError with detailed messages
order = Order.model_validate(raw_payload)
```

#### marshmallow

**Red Flags:**
- `schema.load()` result stored without checking for `ValidationError`
- `many=True` load without error handling per-item
- `dump_default` masking missing required fields on output

```python
from marshmallow import Schema, fields, validate, ValidationError, post_load

class UserSchema(Schema):
    email = fields.Email(required=True)
    name = fields.Str(required=True, validate=validate.Length(min=1, max=100))
    age = fields.Int(required=True, validate=validate.Range(min=18))

    @post_load
    def make_user(self, data, **kwargs):
        return User(**data)  # transform to domain object

schema = UserSchema()
try:
    user = schema.load(request_data)
except ValidationError as e:
    raise BadRequestError(e.messages)
```

#### dataclasses — limitation note

`@dataclass` provides structural convenience but NO runtime type enforcement. Use only for internal data transfer objects where the source is already validated.

```python
from dataclasses import dataclass

# WRONG — treating dataclass as a validator
@dataclass
class Config:
    port: int
    host: str

cfg = Config(**raw_env_dict)  # port could be "abc" — no error raised

# CORRECT — validate with Pydantic first, then convert or use Pydantic directly
class ConfigModel(BaseModel):
    port: int
    host: str

cfg = ConfigModel.model_validate(raw_env_dict)
```

---

### 4. Go Validation — go-playground/validator and Custom

**Red Flags:**
- Struct tags present but `validate.Struct(s)` never called
- `binding:"required"` used in Gin/Echo but missing on nested struct fields
- Validation errors cast to string directly — field path information lost
- Custom validators registered globally but not tested in isolation

```go
package validation

import (
    "fmt"
    "github.com/go-playground/validator/v10"
)

var validate = validator.New()

type CreateOrderRequest struct {
    UserID string      `json:"userId" validate:"required,uuid4"`
    Items  []OrderItem `json:"items"  validate:"required,min=1,dive"`
    Email  string      `json:"email"  validate:"required,email"`
}

type OrderItem struct {
    SKU string `json:"sku" validate:"required,min=1"`
    Qty int    `json:"qty" validate:"required,gt=0"`
}

func ValidateCreateOrder(req CreateOrderRequest) error {
    if err := validate.Struct(req); err != nil {
        var errs validator.ValidationErrors
        if ok := errors.As(err, &errs); ok {
            msgs := make([]string, len(errs))
            for i, e := range errs {
                msgs[i] = fmt.Sprintf("%s: failed %s", e.Field(), e.Tag())
            }
            return fmt.Errorf("validation failed: %s", strings.Join(msgs, "; "))
        }
        return err
    }
    return nil
}

// Custom validator registered at startup
func init() {
    _ = validate.RegisterValidation("sku_format", func(fl validator.FieldLevel) bool {
        return skuRegexp.MatchString(fl.Field().String())
    })
}
```

---

### 5. Java Validation — Bean Validation and Hibernate Validator

**Red Flags:**
- `@Valid` missing on nested object parameters — nested fields not validated
- `@NotNull` used on primitive types — redundant; `@NotBlank` needed for strings
- Controller method parameter missing `@Valid` — Spring skips validation silently
- Custom `ConstraintValidator` not registered in `ValidationConfig`
- `javax.validation` (old) mixed with `jakarta.validation` (new) in the same project

```java
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

// Domain model with Bean Validation annotations
public class CreateOrderRequest {

    @NotBlank(message = "userId is required")
    private String userId;

    @Valid                           // propagates validation into nested objects
    @NotEmpty(message = "items required")
    @Size(min = 1, message = "at least one item required")
    private List items;

    @Email(message = "invalid email format")
    @NotBlank
    private String email;

    // getters / setters
}

public class OrderItem {
    @NotBlank
    private String sku;

    @Positive(message = "qty must be positive")
    private int qty;
}

// Spring controller — @Valid triggers Bean Validation
@RestController
public class OrderController {

    @PostMapping("/orders")
    public ResponseEntity createOrder(@Valid @RequestBody CreateOrderRequest req,
                                                     BindingResult result) {
        if (result.hasErrors()) {
            List errors = result.getFieldErrors().stream()
                .map(e -> e.getField() + ": " + e.getDefaultMessage())
                .collect(Collectors.toList());
            throw new ValidationException(errors);
        }
        return ResponseEntity.ok(orderService.create(req));
    }
}

// Custom constraint — Hibernate Validator extension
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = SkuFormatValidator.class)
public @interface ValidSku {
    String message() default "invalid SKU format";
    Class[] groups() default {};
    Class[] payload() default {};
}

public class SkuFormatValidator implements ConstraintValidator {
    private static final Pattern SKU_PATTERN = Pattern.compile("^[A-Z]{3}-\\d{4}$");

    @Override
    public boolean isValid(String value, ConstraintValidatorContext ctx) {
        return value != null && SKU_PATTERN.matcher(value).matches();
    }
}
```

---

### 6. Schema Evolution and Compatibility

Schema evolution governs how validation schemas change over time without breaking existing clients.

**Additive-safe changes** (backward compatible — old clients still pass validation):
- Adding optional fields with defaults
- Widening accepted value ranges
- Adding new enum values (if consumers use unknown-value fallbacks)

**Breaking changes** (require deprecation cycle or API versioning):
- Removing a field
- Renaming a field
- Narrowing accepted value range (e.g., `max=1000` → `max=500`)
- Adding a new required field
- Changing a field's type

**Red Flags:**
- Required field added to an existing schema without bumping API version
- Enum values removed without checking all consumers
- Field renamed in schema but consumers still send the old key
- No schema version field — impossible to migrate gracefully

**Pattern — additive evolution with default:**
```typescript
// v1 schema
const UserV1 = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
});

// v2 schema — additive: role is optional with a default
const UserV2 = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  role: z.enum(['admin', 'viewer']).default('viewer'), // safe — old clients omit field
});

// v2 schema — BREAKING: makes name required
const UserV2Breaking = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  name: z.string(), // old clients don't send this — BREAKING CHANGE
});
```

**Python / Pydantic — forward-compatible model:**
```python
from pydantic import BaseModel
from typing import Optional

class UserV2(BaseModel):
    model_config = {'extra': 'ignore'}  # tolerate unknown fields from newer producers

    id: str
    email: str
    role: str = 'viewer'          # safe default for old producers
    display_name: Optional[str] = None  # new optional field
```

---

### 7. Coercion vs Strict Parsing

**Coercion** automatically converts values to the target type (`"42"` → `42`). It is convenient but can mask data quality issues and silently accept malformed inputs.

**Strict parsing** rejects any value that is not already the correct type. Prefer strict mode at system boundaries.

**Red Flags:**
- `z.coerce.number()` accepting `""` → `0` or `"abc"` → `NaN`
- Pydantic coercing `"false"` → `False` on a boolean field when the source is an HTTP header that should be `0`/`1`
- Coercion hiding producer bugs: producer sends a string, consumer silently converts — the bug is never fixed

```typescript
// WRONG — coercion masks producer bugs
const Schema = z.object({
  count: z.coerce.number(), // "abc" → NaN, "" → 0; both silently accepted
});

// CORRECT — strict; reject non-numbers at the boundary
const StrictSchema = z.object({
  count: z.number().int().nonnegative(),
});

// ACCEPTABLE — coercion only for known string-to-number scenarios (e.g., quer

…

## Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

- **Author:** [mickeyyaya](https://github.com/mickeyyaya)
- **Source:** [mickeyyaya/refactoring-skills](https://github.com/mickeyyaya/refactoring-skills)
- **License:** MIT

Install and usage instructions live in the source repository linked above.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** no
- **Filesystem access:** no
- **Shell / process execution:** no
- **Environment & secrets:** no
- **Dynamic code execution:** yes

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/skill-mickeyyaya-refactoring-skills-data-validation-schema-patterns
- Seller: https://agentstack.voostack.com/s/mickeyyaya
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
