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:
// 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:
// 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 below for migration instructions.
Native Enums (BREAKING)
Zod v4 consolidated enum handling. If you're using z.nativeEnum(), switch to z.enum():
// 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:
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:
UpdateEndpointnow 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)
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:
// 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:
// 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>- UseZodPipe<T, any>from Zod directly instead
If you were using these, you should use Zod v4's public APIs instead:
// 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:
{
"dependencies": {
"chanfana": "^3.0.0",
"zod": "^4.0.0"
}
}Then run:
npm install2. 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:
# 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:
npm testPay 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:
// 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-detectedbase 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:
const router = fromHono(new Hono(), { base: '/api' });
router.get('/users', UserEndpoint); // Matches at /api/usersCombining 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:
// 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.,/apinotapi) - Must not end with
/(e.g.,/apinot/api/)
Migration steps
- If you use
new Hono().basePath('/api')withfromHono(app, { base: '/api' }), remove thebaseoption fromfromHono(). - If you use
fromHono(app, { base: '/api' })withoutbasePath(), no changes needed — this now also configures Hono's route matching. - Ensure your
basevalues 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:
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:
// 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.
// 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
constraintsMessagesproperty to map constraint violations to user-friendly errors. per_pageis now capped at 100 by default (configurable viamaxPerPageclass 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 for the full list.
Need Help?
If you encounter issues during migration:
- Check the Troubleshooting FAQ
- Review the Zod v4 changelog
- Open an issue on GitHub