# Migration Guide: Chanfana v2 to v3 (Zod v4)

This guide helps you migrate from chanfana v2 (Zod v3) to chanfana v3 (Zod v4). Chanfana v3 brings Zod v4 support with improved tree-shakeability and better performance to your API projects.

## What Changed

Chanfana v3 has been updated to use Zod v4 and `@asteasolutions/zod-to-openapi` v8. While most of the chanfana API remains the same, there are some important changes to be aware of.

## Breaking Changes

### Error Message Formats

Zod v4 improved error messages to be more descriptive and consistent. If your application parses or depends on specific error message formats, you'll need to update them.

**Common changes:**

- `"Required"` → `"Invalid input: expected <type>, received undefined"`
- `"Expected number, received nan"` → `"Invalid input: expected number, received NaN"`
- `"Invalid email"` → `"Invalid email address"`
- `"Invalid uuid"` → `"Invalid UUID"` (capitalization)
- `"Invalid ip"` → `"Invalid IPv4 address"` or `"Invalid IPv6 address"` (more specific)
- Enum errors now use format: `'Invalid option: expected one of "option1"|"option2"'`

**Example:**

```typescript
// Before (Zod v3)
{
  "code": "invalid_type",
  "message": "Required",
  "path": ["username"]
}

// After (Zod v4)
{
  "code": "invalid_type",
  "message": "Invalid input: expected string, received undefined",
  "path": ["username"]
}
```

### Error Object Structure

The `received` field may no longer be present in some error objects. If your code relies on this field, you should update it to handle cases where it's absent.

## Changes Required for Custom Zod Schemas

If you're using Zod directly in your schemas (not through chanfana's parameter helpers), you'll need to update deprecated string format methods:

### String Format Methods (BREAKING)

Zod v4 moved string format validations to top-level functions for better tree-shakeability:

```typescript
// Before (Zod v3)
import { z } from 'zod';

const schema = z.object({
  email: z.string().email(),
  userId: z.string().uuid(),
  createdAt: z.string().datetime(),
  website: z.string().url(),
  birthDate: z.string().date(), // For date-only strings like "2024-01-20"
});

// After (Zod v4)
import { z } from 'zod';

const schema = z.object({
  email: z.email(),              // Top-level function
  userId: z.uuid(),              // Top-level function
  createdAt: z.iso.datetime(),   // Under z.iso namespace
  website: z.url(),              // Top-level function
  birthDate: z.iso.date(),       // Under z.iso namespace for YYYY-MM-DD strings
});
```

**Common replacements:**
- `z.string().email()` → `z.email()`
- `z.string().uuid()` → `z.uuid()`
- `z.string().url()` → `z.url()`
- `z.string().datetime()` → `z.iso.datetime()`
- `z.string().date()` → `z.iso.date()`
- `z.string().ip({ version: "v4" })` → `z.ipv4()`
- `z.string().ip({ version: "v6" })` → `z.ipv6()`

**Note:** Chanfana's parameter helpers (`Email()`, `Uuid()`, `DateTime()`, etc.) have been removed in v3. See [Parameter Helper Functions Removed](#parameter-helper-functions-removed-breaking) below for migration instructions.

### Native Enums (BREAKING)

Zod v4 consolidated enum handling. If you're using `z.nativeEnum()`, switch to `z.enum()`:

```typescript
// Before (Zod v3)
enum Status {
  Active = 'active',
  Inactive = 'inactive',
}

const schema = z.object({
  status: z.nativeEnum(Status),
});

// After (Zod v4)
const schema = z.object({
  status: z.enum(['active', 'inactive']), // Use string array for enum values
});
```

## Bug Fixes

### UpdateEndpoint and Optional Fields with Defaults

**Fixed in this release:**
Zod 4 changed how optional fields with `.default()` values are handled. Previously in Zod 3, defaults were only applied if a field was present but invalid. In Zod 4, defaults are **always applied** even when the field is absent from the input.

This caused an issue where `UpdateEndpoint` would incorrectly reset optional fields to their default values during partial updates, even when those fields weren't included in the update request.

**Example:**
```typescript
const UserSchema = z.object({
  id: z.number().int(),
  username: z.string(),
  email: z.email(),
  age: z.number().int().optional().default(18),
});

// Database record: { id: 1, username: "john", age: 30 }

// Update only username:
PUT /users/1
{ "username": "johndoe", "email": "john@example.com" }

// ✅ Correctly keeps age as 30 (not reset to default 18)
```

**What we fixed:**
- `UpdateEndpoint` now checks the raw request body to determine which fields were actually sent
- Only fields present in the request are used to update the record
- This preserves existing values for fields not included in partial updates

**No action required** - This fix is automatic and restores the expected behavior for partial updates.

## New Features

### New `getUnvalidatedData()` Method

A new method `getUnvalidatedData()` is now available on `OpenAPIRoute`. This returns the raw request data **before** Zod applies defaults or transformations.

This is useful when you need to distinguish between:
- A field that was explicitly sent with a value
- A field that was absent from the request (but may have a Zod default)

```typescript
import { OpenAPIRoute } from 'chanfana';
import { z } from 'zod';

class MyEndpoint extends OpenAPIRoute {
  schema = {
    request: {
      body: {
        content: {
          'application/json': {
            schema: z.object({
              name: z.string(),
              status: z.enum(['active', 'inactive']).optional().default('active'),
            }),
          },
        },
      },
    },
  };

  async handle() {
    const validated = await this.getValidatedData();
    // validated.body = { name: "test", status: "active" } (default applied)

    const raw = await this.getUnvalidatedData();
    // raw.body = { name: "test" } (no status field)

    // Check if status was actually sent
    if ('status' in raw.body) {
      // User explicitly provided status
    } else {
      // Status is using default value
    }

    return { success: true };
  }
}
```

## Parameter Helper Functions Removed (BREAKING)

All parameter helper functions have been removed from Chanfana. You must now use native Zod schemas directly.

**Removed functions:**
- `Str()`, `Num()`, `Int()`, `Bool()`
- `DateTime()`, `DateOnly()`
- `Email()`, `Uuid()`, `Hostname()`
- `Ipv4()`, `Ipv6()`, `Ip()`
- `Regex()`, `Enumeration()`
- `convertParams()`

### Migration Guide

Replace the helper functions with their Zod equivalents:

| Old Helper | New Zod Equivalent |
|------------|-------------------|
| `Str()` | `z.string()` |
| `Num()` | `z.number()` |
| `Int()` | `z.number().int()` |
| `Bool()` | `z.boolean()` |
| `DateTime()` | `z.iso.datetime()` |
| `DateOnly()` | `z.iso.date()` |
| `Email()` | `z.email()` |
| `Uuid()` | `z.uuid()` |
| `Ipv4()` | `z.ipv4()` |
| `Ipv6()` | `z.ipv6()` |
| `Ip()` | `z.union([z.ipv4(), z.ipv6()])` |
| `Hostname()` | `z.string().regex(/^(([a-zA-Z0-9]\|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]\|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/)` |
| `Regex({ pattern })` | `z.string().regex(pattern)` |
| `Enumeration({ values })` | `z.enum([...])` |

**Example migration:**

```typescript
// Before
import { Str, Int, Email, Enumeration } from 'chanfana';

const schema = z.object({
  name: Str({ description: 'User name', example: 'John' }),
  age: Int({ description: 'User age', default: 18 }),
  email: Email(),
  status: Enumeration({ values: ['active', 'inactive'], default: 'active' }),
});

// After
import { z } from 'zod';

const schema = z.object({
  name: z.string().describe('User name').openapi({ example: 'John' }),
  age: z.number().int().default(18).describe('User age'),
  email: z.email(),
  status: z.enum(['active', 'inactive']).default('active'),
});
```

For case-insensitive enumerations:

```typescript
// Before
Enumeration({ values: ['json', 'csv'], enumCaseSensitive: false })

// After
z.preprocess((val) => String(val).toLowerCase(), z.enum(['json', 'csv']))
  .openapi({ enum: ['json', 'csv'] })
```

## Removed Exports

The following items have been removed from the public API:

**Utility Functions** (relied on Zod's internal APIs):
- `isAnyZodType()`
- `isSpecificZodType()`

**Type Aliases** (unnecessary abstraction over Zod v4 types):
- `ZodEffects<T, Output, Input>` - Use `ZodPipe<T, any>` from Zod directly instead

If you were using these, you should use Zod v4's public APIs instead:

```typescript
// isAnyZodType replacement
// Before
import { isAnyZodType } from 'chanfana';
if (isAnyZodType(schema)) { ... }

// After
import { z } from 'zod';
if (schema instanceof z.ZodType) { ... }

// ZodEffects replacement
// Before
import type { ZodEffects } from 'chanfana';
type MyParam = ZodEffects<SomeSchema, Output, Input>;

// After
import type { ZodPipe } from 'zod';
type MyParam = ZodPipe<SomeSchema, any>;
```

**Note:** `AnyZodObject` remains exported as it's a commonly used type in the public API.

## Migration Steps

### 1. Update Dependencies

Update your `package.json`:

```json
{
  "dependencies": {
    "chanfana": "^3.0.0",
    "zod": "^4.0.0"
  }
}
```

Then run:

```bash
npm install
```

### 2. Update Deprecated Zod Methods (If Using Custom Schemas)

If you're using Zod directly in your schemas, search for and replace deprecated string format methods:

```bash
# Search for patterns that need updating
grep -r "z\.string()\.(email\|uuid\|datetime\|url\|date)" .
grep -r "z\.nativeEnum" .
```

Update according to the "Changes Required for Custom Zod Schemas" section above.

### 3. Update Error Message Handling (If Applicable)

If your code depends on specific error message formats (e.g., for testing or client-side validation display), update those expectations to match the new Zod v4 formats shown above.

### 4. Test Your Application

Run your test suite to catch any issues:

```bash
npm test
```

Pay special attention to:
- Validation error handling tests
- API response format tests
- Error message assertions

## Benefits of Zod v4

After migrating, you'll benefit from:

- **Better Tree-Shakeability:** Smaller bundle sizes thanks to improved code splitting
- **Improved Error Messages:** More descriptive and consistent validation errors
- **Better Performance:** Optimized validation logic
- **Enhanced Type Safety:** Improved TypeScript inference

## Hono Base Path Changes (v3.1)

Chanfana v3.1 introduces improved handling of Hono's `basePath()` method. These changes affect how you configure base paths for Hono applications.

### Auto-detection of Hono's `basePath()`

Chanfana now automatically detects when a Hono instance was created with `basePath()`. You no longer need to pass the `base` option separately:

```typescript
// Before: Had to pass base to both Hono and chanfana
const app = new Hono().basePath('/api');
const router = fromHono(app, { base: '/api' }); // ❌ Now throws an error

// After: Just use basePath() — chanfana detects it automatically
const app = new Hono().basePath('/api');
const router = fromHono(app); // ✅ Base path "/api" auto-detected
```

### `base` option now applies `basePath()` for Hono

When using the `base` option with Hono (without a pre-existing `basePath()`), Chanfana now calls Hono's `basePath()` internally. This means routes actually match at the prefixed path, not just in the OpenAPI schema:

```typescript
const router = fromHono(new Hono(), { base: '/api' });
router.get('/users', UserEndpoint); // Matches at /api/users
```

### Combining `basePath()` and `base` throws an error

Using both Hono's `basePath()` and chanfana's `base` option now throws a descriptive error to prevent double-prefixing:

```typescript
// This throws an error with migration guidance:
fromHono(new Hono().basePath('/api'), { base: '/v1' });
```

### Base path format validation

The `base` option is now validated:
- Must start with `/` (e.g., `/api` not `api`)
- Must not end with `/` (e.g., `/api` not `/api/`)

### Migration steps

1. If you use `new Hono().basePath('/api')` with `fromHono(app, { base: '/api' })`, remove the `base` option from `fromHono()`.
2. If you use `fromHono(app, { base: '/api' })` without `basePath()`, no changes needed — this now also configures Hono's route matching.
3. Ensure your `base` values start with `/` and don't end with `/`.

## Hono Error Handling Changes (v3.1)

Chanfana v3.1 changes how errors are handled when using the Hono adapter.

### Errors now flow through Hono's `onError`

Previously, chanfana caught all errors (validation errors, `ApiException` subclasses) internally and returned formatted JSON responses directly. Hono's `app.onError` handler never saw these errors.

Now, chanfana converts these errors into Hono `HTTPException` instances and re-throws them, so they flow through `app.onError`. The `HTTPException` wraps chanfana's standard JSON error response, accessible via `err.getResponse()`.

**If you don't have an `onError` handler:** No action needed. Hono's default handler calls `HTTPException.getResponse()`, which returns the same formatted response as before.

**If you have an `onError` handler:** You will now receive chanfana errors (validation failures, `NotFoundException`, etc.) as `HTTPException` instances. Update your handler to check for `HTTPException`:

```typescript
import { HTTPException } from 'hono/http-exception';

app.onError((err, c) => {
    if (err instanceof HTTPException) {
        // Chanfana error -- return the pre-formatted response
        return err.getResponse();
    }

    // Handle other errors
    return c.json({ error: 'Internal Server Error' }, 500);
});
```

### `handleValidationError()` removed

The `handleValidationError()` method has been removed from `OpenAPIRoute`. If you were overriding this method to customize validation error formatting, use Hono's `app.onError` handler instead to customize error responses centrally.

**No changes to itty-router behavior.**

## Migrating to Chanfana 3.1

### `contentJson()` Requires Zod Schemas (BREAKING)

`contentJson()` no longer accepts plain objects or JavaScript constructors. It now requires a Zod schema:

```typescript
// Before (Chanfana v2/v3.0)
contentJson({ success: Boolean, result: { id: String } })

// After (Chanfana v3.1)
import { z } from 'zod';
import { contentJson } from 'chanfana';

contentJson(z.object({ success: z.boolean(), result: z.object({ id: z.string() }) }))
```

### `raiseUnknownParameters` Now Enforced

The `raiseUnknownParameters` router option was previously accepted but not enforced. It is now fully functional. If you had this option set to `true`, unknown query/path/header parameters will now cause 400 validation errors.

```typescript
// If you see unexpected 400 errors after upgrading, check your router options:
const router = fromHono(app, {
  raiseUnknownParameters: false, // Set to false to allow unknown parameters
});
```

### D1 Endpoint Security Improvements

Several D1 endpoint behaviors have changed for security:

- **Delete and Update operations** now only apply primary key filters to WHERE clauses. If you relied on filtering by non-primary-key fields, that will no longer work.
- **Database error messages** are no longer exposed in responses. Use the `constraintsMessages` property to map constraint violations to user-friendly errors.
- **`per_page` is now capped** at 100 by default (configurable via `maxPerPage` class property).

### New Exception Types

Chanfana 3.1 adds a full set of HTTP exception classes. Consider replacing generic `ApiException` usage with specific types:

| Exception | Status | Use Case |
|-----------|--------|----------|
| `UnauthorizedException` | 401 | Authentication failures |
| `ForbiddenException` | 403 | Authorization failures |
| `ConflictException` | 409 | Duplicate resources |
| `TooManyRequestsException` | 429 | Rate limiting (sets `Retry-After` header) |
| `ServiceUnavailableException` | 503 | Maintenance mode (sets `Retry-After` header) |

See [Error Handling](./error-handling.md) for the full list.

## Need Help?

If you encounter issues during migration:

1. Check the [Troubleshooting FAQ](/troubleshooting-and-faq)
2. Review the [Zod v4 changelog](https://zod.dev/v4/changelog)
3. Open an issue on [GitHub](https://github.com/cloudflare/chanfana/issues)
