Skip to content

Validation ​

itty-spec automatically validates all incoming requests against your contract schemas before your handlers run. This ensures type safety and data integrity.

How Validation Works ​

Validation happens automatically in the middleware chain:

Path Parameter Validation ​

Path parameters are extracted from the URL and validated against your schema.

Automatic Extraction ​

When you use path patterns like /users/:id, parameters are automatically extracted as strings:

ts
const contract = createContract({
  getUser: {
    path: "/users/:id",  // id is extracted as string
    method: "GET",
    responses: { /* ... */ },
  },
});

// In handler:
const { id } = request.validatedParams; // { id: string }

Explicit Validation ​

For stricter validation, provide a pathParams schema:

ts
const contract = createContract({
  getUser: {
    path: "/users/:id",
    method: "GET",
    pathParams: z.object({
      id: z.string().uuid(),  // Validates UUID format
    }),
    responses: { /* ... */ },
  },
});

// Validation fails if id is not a valid UUID
// GET /users/not-a-uuid → 400 Bad Request

Validation Errors ​

Invalid path parameters return a 400 error:

json
{
  "error": "Validation failed",
  "details": [
    {
      "path": ["id"],
      "message": "Invalid uuid"
    }
  ]
}

Query Parameter Validation ​

Query parameters are parsed from the URL query string and validated.

Basic Validation ​

ts
const contract = createContract({
  searchUsers: {
    path: "/users",
    method: "GET",
    query: z.object({
      q: z.string().min(1),           // Required
      limit: z.number().min(1).max(100).default(10),
      offset: z.number().optional(),
    }),
    responses: { /* ... */ },
  },
});

Type Coercion ​

Query parameters are strings in URLs, so you may need to transform them:

ts
query: z.object({
  page: z.string()
    .transform(Number)
    .pipe(z.number().min(1)),
  limit: z.string()
    .transform(Number)
    .pipe(z.number().min(1).max(100)),
  active: z.enum(['true', 'false'])
    .transform(val => val === 'true'),
})

Array Parameters ​

Handle array query parameters:

ts
// ?tags=tag1&tags=tag2
query: z.object({
  tags: z.array(z.string()).optional(),
  ids: z.array(z.string().uuid()),
})

Missing Parameters ​

  • Required parameters without defaults: validation fails
  • Optional parameters: set to undefined
  • Parameters with defaults: use default value

Header Validation ​

Headers are normalized to lowercase and validated against your schema.

Basic Validation ​

ts
const contract = createContract({
  createUser: {
    path: "/users",
    method: "POST",
    headers: z.object({
      authorization: z.string(),
      "content-type": z.literal("application/json"),
      "x-api-key": z.string(),
    }),
    responses: { /* ... */ },
  },
});

Header Normalization ​

All header keys are normalized to lowercase:

ts
// Schema definition
headers: z.object({
  "Authorization": z.string(),      // Capital A
  "X-API-Key": z.string(),         // Mixed case
})

// Runtime access (always lowercase)
request.validatedHeaders.get("authorization");  // ✅ Works
request.validatedHeaders.get("x-api-key");     // ✅ Works

Comma-Separated Values ​

Some headers (like Accept) may contain comma-separated values. itty-spec handles this automatically:

ts
headers: z.object({
  accept: z.union([
    z.literal("application/json"),
    z.literal("text/html"),
  ]),
})

// Request: Accept: application/json, text/html
// Validation tries each value until one matches

Body Validation ​

Request bodies are validated based on the Content-Type header.

Single Content Type ​

ts
const contract = createContract({
  createUser: {
    path: "/users",
    method: "POST",
    requests: {
      "application/json": {
        body: z.object({
          name: z.string().min(1),
          email: z.string().email(),
        }),
      },
    },
    responses: { /* ... */ },
  },
});

Multiple Content Types ​

When multiple content types are defined, validation uses the request's Content-Type header:

ts
requests: {
  "application/json": {
    body: z.object({ name: z.string() }),
  },
  "application/xml": {
    body: z.string(),  // XML as string
  },
}

// Request with Content-Type: application/json
// → Validates against JSON schema
// Request with Content-Type: application/xml
// → Validates against XML schema

Missing Content-Type ​

If Content-Type is required but missing, validation fails with 400:

json
{
  "error": "Content-Type header is required"
}

Unsupported Content-Type ​

If the Content-Type doesn't match any defined schema:

json
{
  "error": "Unsupported Content-Type: text/plain. Supported types: application/json, application/xml"
}

Empty Body ​

If no body is sent:

  • Request body is set to {} (empty object)
  • Validation passes if body schema is optional or has defaults

Validation Error Handling ​

When validation fails, itty-spec automatically returns a 400 Bad Request response.

Error Response Format ​

json
{
  "error": "Validation failed",
  "details": [
    {
      "path": ["email"],
      "message": "Invalid email"
    },
    {
      "path": ["age"],
      "message": "Expected number, received string"
    }
  ]
}

Custom Error Handling ​

You can customize error handling in middleware:

ts
const router = createRouter({
  contract,
  handlers,
  before: [
    async (request) => {
      try {
        // Validation happens automatically
      } catch (error) {
        if (error instanceof Error && 'issues' in error) {
          // Custom validation error handling
          return new Response(
            JSON.stringify({
              customError: "Validation failed",
              issues: error.issues,
            }),
            { status: 400 }
          );
        }
        throw error;
      }
    },
  ],
});

Custom Validation Patterns ​

Conditional Validation ​

Use Zod's refinement for conditional validation:

ts
const schema = z.object({
  type: z.enum(['email', 'phone']),
  value: z.string(),
}).refine(
  (data) => {
    if (data.type === 'email') {
      return z.string().email().safeParse(data.value).success;
    }
    return /^\d{10}$/.test(data.value);
  },
  { message: "Invalid value for type" }
);

Async Validation ​

For database lookups or external API calls:

ts
const schema = z.object({
  email: z.string().email(),
}).refine(
  async (data) => {
    const exists = await checkEmailExists(data.email);
    return !exists;
  },
  { message: "Email already exists" }
);

Cross-Field Validation ​

Validate relationships between fields:

ts
const schema = z.object({
  password: z.string().min(8),
  confirmPassword: z.string(),
}).refine(
  (data) => data.password === data.confirmPassword,
  {
    message: "Passwords don't match",
    path: ["confirmPassword"],
  }
);

Validation Best Practices ​

1. Validate Early, Validate Often ​

Define schemas for all inputs to catch errors early:

ts
// ✅ Good - validates everything
const contract = createContract({
  getUser: {
    path: "/users/:id",
    pathParams: z.object({ id: z.string().uuid() }),
    query: z.object({ include: z.array(z.string()).optional() }),
    headers: z.object({ authorization: z.string() }),
    responses: { /* ... */ },
  },
});

2. Use Descriptive Error Messages ​

ts
// ✅ Good
z.string().email({ message: "Please provide a valid email address" })

// ❌ Bad
z.string().email()

3. Provide Sensible Defaults ​

ts
query: z.object({
  page: z.number().min(1).default(1),
  limit: z.number().min(1).max(100).default(10),
  sort: z.enum(['asc', 'desc']).default('asc'),
})

4. Validate Path Parameters Explicitly ​

For non-string types, always provide explicit schemas:

ts
// ✅ Good
pathParams: z.object({
  id: z.string().uuid(),
  version: z.string().transform(Number).pipe(z.number().int()),
})

5. Handle Optional Fields Properly ​

ts
// ✅ Good - explicit optional
query: z.object({
  filter: z.string().optional(),
  limit: z.number().default(10),  // Has default, so always present
})

Released under the MIT License.