@libar-dev/zod-convex-core
v0.1.0
Published
Core Zod v4 to Convex validator conversion engine with extensible codec registry (Zod 4.1.0+)
Maintainers
Readme
@libar-dev/zod-convex-core
Core Zod v4 to Convex validator conversion engine with extensible codec registry and depth protection.
Quick Start
Get started in 5 minutes with the most common use case: converting Zod schemas to Convex validators.
Installation
npm install @libar-dev/zod-convex-core zod@^4Basic Usage
Convert a Zod schema to a Convex validator for mutations:
import { zodToConvex } from '@libar-dev/zod-convex-core';
import { z } from 'zod';
import { mutation } from './_generated/server';
const CreateUserSchema = z.object({
name: z.string(),
email: z.string().email(),
age: z.number().optional(),
}).strict();
export const createUser = mutation({
args: zodToConvex(CreateUserSchema),
handler: async (ctx, args) => {
// args is fully typed: { name: string; email: string; age?: number }
return await ctx.db.insert('users', args);
},
});That's it! Full type safety with zero boilerplate.
Table Definitions
Use the same pattern for table schemas:
import { defineTable } from 'convex/server';
import { zodToConvex } from '@libar-dev/zod-convex-core';
const UserTableSchema = z.object({
name: z.string(),
email: z.string(),
createdAt: z.number(),
}).strict();
export const users = defineTable(zodToConvex(UserTableSchema))
.index('by_email', ['email']);Next Steps
- Advanced Patterns: See API Patterns for
zodToConvex()vszodToConvexFields() - Production Optimization: Check Build-Time vs Runtime Usage for 90% memory reduction
- Custom Types: Learn about Custom Type Registration for Date, BigInt, and more
- Type Safety: Explore Type System Features for branded types and const generics
- Best Practices: Read Best Practices for security and maintainability
Why This Package?
Community Context
At the time of writing, the official convex-helpers library does not yet support Zod v4:
- Open Issue: convex-helpers#558 - Zod v4 compatibility request (filed by community, no resolution yet)
- Current State:
convex-helperspins to Zod v3 via'zod/v3'imports with no v4 support - Memory Constraints: Runtime Zod usage can hit Convex's 64MB memory limit in non-trivial applications (documented in our benchmarks)
This package addresses these documented gaps while maintaining compatibility with Convex's type system.
What This Package Provides
✅ Zod v4 Native Support - Uses .brand(), .unwrap(), and modern Zod v4 APIs ✅ Extensible
Registry - Plugin custom type converters with registerBaseCodec() ✅ Depth Protection -
Configurable limits guard against TypeScript structural depth errors ✅ Enhanced Optional
Handling - Proper distinction between optional() and nullable() semantics ✅ Type-Safe ID
Support - Integration with @libar-dev/zod-convex-ids for table name tracking
When to Choose This Package
| Feature | convex-helpers | @libar-dev/zod-convex-core |
| ------------------------ | ------------------------------------------ | --------------------------------------------------------- |
| Zod Version | v3 only (via 'zod/v3') | Native v4 support |
| Memory Optimization | Runtime Zod (can hit 64MB in complex apps) | Build-time generation option (90% reduction in our tests) |
| Custom Type Registry | Limited | Extensible via registerBaseCodec() |
| Depth Protection | None | Configurable limits (default: 16 levels) |
| Optional Semantics | Basic | Enhanced .optional().nullable() handling |
| Bundle Size Impact | ~60-65MB for typical schemas | ~10-15MB with @libar-dev/zod-convex-gen |
Choose convex-helpers if:
- You're on Zod v3 and don't need to upgrade
- You have simple schemas under memory limits
- Basic Zod-to-Convex conversion is sufficient
Choose @libar-dev/zod-convex-core if:
- You're on Zod v4 or planning to upgrade
- You need memory optimization for production
- You have complex nested schemas requiring depth protection
- You want extensible type registry for custom conversions
Zod 4 Support
This package is built for Zod 4.1.0+ and leverages modern Zod 4 features for optimal performance and developer experience.
Supported Zod 4 String Formats
All Zod 4 top-level string format functions are fully supported:
Email & Web:
z.email()- Email address validationz.url()- URL validation
Identifiers:
z.uuid()- Strict RFC 9562/4122 UUID validationz.guid()- Permissive GUID (8-4-4-4-12 pattern)z.jwt()- JWT token format
Network:
z.ipv4()- IPv4 addressz.ipv6()- IPv6 addressz.cidrv4()- IPv4 CIDR notationz.cidrv6()- IPv6 CIDR notation
Encoding:
z.base64()- Base64 encodingz.base64url()- URL-safe base64
Telephony:
z.e164()- E.164 phone number format
Date & Time (ISO 8601):
z.iso.date()- ISO date (YYYY-MM-DD)z.iso.time()- ISO time (HH:MM:SS)z.iso.datetime()- ISO datetimez.iso.duration()- ISO 8601 duration
All string formats convert to v.string() in Convex validators. Runtime validation is handled by Zod, while Convex validators define the storage format.
Performance Benefits
By using Zod 4, you automatically benefit from:
- 14x faster string parsing
- 7x faster array parsing
- 6.5x faster object parsing
- 100x fewer TypeScript instantiations (faster builds, better IDE performance)
- 2.3x smaller bundle size
Migration from Deprecated Methods
If you're using deprecated Zod 3 string methods, migrate to top-level functions:
// Deprecated (Zod 3 style)
z.string().email()
z.string().uuid()
z.string().url()
// Modern (Zod 4 style - tree-shakable)
z.email()
z.uuid()
z.url()Both patterns work, but top-level functions are tree-shakable and recommended.
Installation
npm install @libar-dev/zod-convex-core zod@^4.1.0Peer Dependencies:
convex>= 1.30.0 < 2.0.0zod^4.1.0
API Patterns
zodToConvex() vs zodToConvexFields()
This package provides two main conversion functions. Understanding when to use each is important for effective development.
zodToConvex() - The Universal Converter (Recommended Default)
Use zodToConvex() as your default choice. It intelligently handles both complete schemas and plain objects.
import { zodToConvex } from '@libar-dev/zod-convex-core';
import { z } from 'zod';
// Works with ZodObject schemas
const UserSchema = z.object({
name: z.string(),
age: z.number().optional(),
});
// Use in mutations/queries/actions
export const createUser = mutation({
args: zodToConvex(UserSchema), // ✅ Recommended pattern
handler: async (ctx, args) => { ... }
});
// Also works with plain objects (auto-delegates to zodToConvexFields)
export const updateUser = mutation({
args: zodToConvex({
id: zid('users'),
name: z.string(),
}), // ✅ Also correct - auto-converts plain object
handler: async (ctx, args) => { ... }
});zodToConvexFields() - The Field Converter (Special Cases)
Use zodToConvexFields() only for spreading fields into larger definitions, typically when combining Zod schemas with native Convex validators.
import { zodToConvexFields } from '@libar-dev/zod-convex-core';
import { defineTable } from 'convex/server';
import { v } from 'convex/values';
// Hybrid table pattern: Zod fields + Convex enum validators
const BaseSchema = z
.object({
name: z.string(),
email: z.string().email(),
amount: z.number(),
})
.strict();
export const users = defineTable({
...zodToConvexFields(BaseSchema.shape), // ✅ Spread Zod fields
// Add Convex native validators for better enum type inference
status: v.union(v.literal('active'), v.literal('inactive'), v.literal('pending')),
role: v.union(v.literal('admin'), v.literal('user')),
})
.index('by_email', ['email'])
.index('by_status', ['status']);When to Use Each Pattern
| Scenario | Function | Example |
| ---------------------------------- | --------------------- | --------------------------------------------------------------------------- |
| Mutation/Query/Action args | zodToConvex() | args: zodToConvex(Schema) |
| Simple table definitions | zodToConvex() | defineTable(zodToConvex(Schema)) |
| Hybrid tables (Zod + Convex enums) | zodToConvexFields() | defineTable({ ...zodToConvexFields(schema.shape), status: v.union(...) }) |
| Plain object validation | zodToConvex() | zodToConvex({ field: z.string() }) |
| Spreading fields only | zodToConvexFields() | { ...zodToConvexFields(fields), extra: v.string() } |
Implementation Detail
zodToConvex() automatically detects input types and delegates appropriately:
export function zodToConvex(zod, options) {
// Detects plain objects and uses zodToConvexFields internally
if (typeof zod === 'object' && !(zod instanceof z.ZodType)) {
return zodToConvexFields(zod, options);
}
return zodToConvexInternal(zod, options);
}Schema Authoring Best Practices
Type Alias Exports for Hybrid Pattern
When combining this package with @libar-dev/zod-convex-gen for build-time validator generation (the recommended approach for production), schema files must export type aliases.
Why This Matters:
The hybrid pattern uses type-only imports (import type) to eliminate Zod from the runtime bundle while preserving type safety. However, type-only imports are erased at compile time. Using z.infer<typeof Schema> with a type-only import causes TypeScript errors because typeof requires a runtime value.
Schema File Pattern:
// src/validation-schemas/domain/myFunction.ts
import { z } from 'zod';
import { createIdValidators } from '@libar-dev/zod-convex-ids';
const ids = createIdValidators({
user: 'users',
company: 'companies',
} as const);
// 1. Export the schema (for @libar-dev/zod-convex-gen)
export const CreateUserArgsSchema = z.object({
email: z.string().email(),
name: z.string(),
companyId: ids.companyId()
}).strict();
// 2. Export the type alias (for consumers) - REQUIRED
export type CreateUserArgs = z.infer<typeof CreateUserArgsSchema>;Consumer File Pattern:
// convex/domain/users.ts
// Import type alias (NOT schema) - zero runtime cost
import type { CreateUserArgs } from '../../src/validation-schemas/domain/users';
// Import generated validators (from @libar-dev/zod-convex-gen)
import { createUserArgsFields } from '../generatedValidators/users';
export const createUser = mutation({
args: createUserArgsFields, // Runtime: Pure Convex validators
handler: async (ctx, args: CreateUserArgs) => { // Types: Type alias from Zod
// Full type safety, zero Zod in bundle (90% memory reduction)
await ctx.db.insert('users', {
email: args.email,
name: args.name,
companyId: args.companyId
});
}
});Naming Convention:
- Function arguments: Remove "Schema" suffix (
CreateUserArgsSchema→CreateUserArgs) - Table schemas: Use singular form (
UsersTableSchema→User)
Common Mistakes:
// ❌ Missing type alias export
export const MyArgsSchema = z.object({...}).strict();
// Missing: export type MyArgs = z.infer<typeof MyArgsSchema>;
// ❌ Using typeof with type-only import (breaks compilation)
import type { MyArgsSchema } from '...';
handler: async (ctx, args: z.infer<typeof MyArgsSchema>) => { ... } // ERROR!
// ✅ Correct: Import and use type alias
import type { MyArgs } from '...';
handler: async (ctx, args: MyArgs) => { ... }When to Use This Pattern:
- Production applications: Use
@libar-dev/zod-convex-genwith type aliases (90% memory reduction) - Development/prototyping: Use runtime
zodToConvex()directly (simpler, no build step)
See Also:
@libar-dev/zod-convex-gen- Build-time validator generation package- Getting Started Guide
This means you can safely use zodToConvex() for most scenarios, and it will handle the conversion
correctly.
Basic Usage
Converting Zod Schemas
import { zodToConvex } from '@libar-dev/zod-convex-core';
import { z } from 'zod';
import { mutation } from './_generated/server';
const CreatePostSchema = z.object({
title: z.string().min(1).max(100),
content: z.string(),
published: z.boolean().default(false),
tags: z.array(z.string()).optional(),
});
export const createPost = mutation({
args: zodToConvex(CreatePostSchema),
handler: async (ctx, args) => {
// args is fully typed and validated
return await ctx.db.insert('posts', args);
},
});Table Definitions
import { defineTable } from 'convex/server';
import { zodToConvex } from '@libar-dev/zod-convex-core';
const PostSchema = z.object({
title: z.string(),
content: z.string(),
authorId: zid('users'),
published: z.boolean(),
createdAt: z.number(),
});
// Simple table (all Zod)
export const posts = defineTable(zodToConvex(PostSchema))
.index('by_author', ['authorId'])
.index('by_published', ['published', 'createdAt']);Build-Time vs Runtime Usage
This package provides runtime conversion of Zod schemas to Convex validators. For production applications hitting Convex's 64MB memory limit, consider build-time generation instead.
When to Use Runtime Conversion (This Package)
Use @libar-dev/zod-convex-core when:
- ✅ Development and prototyping
- ✅ Applications under 64MB memory limit
- ✅ Need dynamic schema composition
- ✅ Testing and experimentation
Memory Profile:
- Table schemas with Zod: ~27MB
- Function args with Zod: ~18MB
- Complex unions with Zod: ~10MB
- Total: ~55-60MB of 64MB limit
When to Use Build-Time Generation
Use @libar-dev/zod-convex-gen when:
- ✅ Production deployment
- ✅ Hitting 64MB memory limit
- ✅ Want 90% memory reduction
- ✅ Schema definitions are stable
- ✅ Need maximum performance
Memory Profile:
- Table schemas (generated): ~3MB
- Function args (hybrid pattern): ~10-15MB total
- Total: ~10-15MB of 64MB limit
Memory Comparison:
| Approach | Bundle Size | Memory Usage | Zod in Bundle | Best For | | ------------------------------ | ----------- | ------------ | ------------- | ----------- | | Runtime Zod (this package) | 60-65MB | High | ✅ Yes | Development | | Build-Time Generation | 10-15MB | Low | ❌ No | Production |
Use Case Decision Matrix
| Scenario | Recommended Approach | Reasoning | | -------------------------- | --------------------- | -------------------------------------------------------- | | Early development | Runtime Zod | Fast iteration, no build step | | Dynamic schema composition | Runtime Zod | Schema generation at runtime | | Production app < 40MB | Runtime Zod | No memory pressure | | Production app > 50MB | Build-time generation | Avoid memory limit | | Large schema collections | Build-time generation | Significant savings | | Hybrid approach needed | Both | Use generated for tables, runtime for dynamic validation |
Important Limitations & Fallback Behavior
⚠️ Zod Validation Features Not Preserved
The following Zod features lose their validation logic during conversion and fall back to
v.any():
.refine() - Custom Validation Logic
// ❌ Validation logic is LOST
const EmailSchema = z.string().refine(s => s.includes('@'), {
message: 'Must be valid email',
});
zodToConvex(EmailSchema); // Returns v.any() ⚠️
// ✅ SOLUTION: Apply refinements at function boundaries, not table schemas
export const createUser = mutation({
args: zodToConvex(
z.object({
email: z.string(), // Structure only in table
})
),
handler: async (ctx, args) => {
// Validate business rules here
const validated = EmailSchema.parse(args.email);
},
});.transform() - Type Transformations
// ❌ Transform logic is LOST (unless registered codec exists)
const UpperSchema = z.string().transform(s => s.toUpperCase());
zodToConvex(UpperSchema); // Returns v.any() ⚠️
// ✅ EXCEPTION: Date has built-in codec support
const DateSchema = z.date(); // Automatically uses Date codec
zodToConvex(DateSchema); // Returns v.float64() with Date ↔ number conversion ✅
// ✅ SOLUTION: Register custom codec for your transform
import { registerBaseCodec } from '@libar-dev/zod-convex-core';
registerBaseCodec({
check: schema => schema instanceof z.ZodEffects && schema._def.effect.type === 'transform',
toValidator: () => v.string(),
fromConvex: value => value,
toConvex: value => (typeof value === 'string' ? value.toUpperCase() : value),
});.pipe() - Schema Chaining
// ❌ Falls back to v.any() unless registered codec
const PipedSchema = z.string().pipe(z.string().min(5));
zodToConvex(PipedSchema); // Returns v.any() ⚠️Why This Happens:
- Convex validators validate structure only (types, presence, constraints)
- Zod refinements contain arbitrary JavaScript logic that can't be serialized to Convex
- Only exception: Registered codecs (like Date) provide bidirectional conversion rules
Best Practices:
- Table schemas → Use structural validation only (types, optionality, unions)
- Function arguments → Apply
.refine()and business logic validation - Custom types → Register codecs via
@libar-dev/zod-convex-codecspackage - Date handling → Built-in codec automatically converts Date ↔ timestamp
See Also:
- @libar-dev/zod-convex-codecs - Custom type codec library
- Codec Registry Documentation - How to register custom codecs
Migration Path
When you're ready to optimize for production:
Step 1: Install Generator
npm install --save-dev @libar-dev/zod-convex-genStep 2: Generate Validators
npx zod-convex-genStep 3: Update Imports
// Before (Runtime - uses this package)
import { zodToConvex } from '@libar-dev/zod-convex-core';
import { UsersTableSchema } from './schemas/users';
export default defineSchema({
users: defineTable(zodToConvex(UsersTableSchema)),
});
// After (Build-time - eliminates Zod from bundle)
import { usersFields, usersEnums } from './generatedValidators/users';
export default defineSchema({
users: defineTable({
...usersFields,
...usersEnums,
}),
});Result: 90% memory reduction while maintaining full type safety
Related Packages
- @libar-dev/zod-convex-gen - Build-time validator generation (90% memory reduction)
- @libar-dev/zod-convex-builders - Function wrappers with Zod validation (supports both runtime and generated validators)
Key Insight: This package (@libar-dev/zod-convex-core) is the foundation that powers both
runtime conversion AND build-time generation. The generator uses this package's conversion logic at
build-time to eliminate Zod from the runtime bundle.
Type Safety
Both functions preserve full TypeScript type inference:
// Type is fully inferred
const validator = zodToConvex(UserSchema);
// validator: ConvexValidator<{ name: string; age?: number }>
// Field types are preserved
const fields = zodToConvexFields(UserSchema.shape);
// fields: { name: VString; age: VOptional<VNumber> }Advanced Features
Depth Limit Protection
The converter guards against excessive recursion depth that could cause:
- Slow TypeScript type checking
- Memory exhaustion during codegen
- Exceeding Convex's 64MB initialization limit
- TypeScript structural depth errors
Default Behavior:
- Maximum depth: 16 levels (configurable)
- Warning threshold: 80% of max (level 13 by default)
- Fallback behavior: Short-circuits to
v.any()when limit exceeded
Configuration:
import { zodToConvex } from '@libar-dev/zod-convex-core';
// Use custom depth limit
const validator = zodToConvex(schema, {
maxDepth: 12, // Adjust based on schema complexity
onWarning: warning => {
if (warning.type === 'depth_limit_warning') {
console.warn(`Schema depth approaching limit at ${warning.depth} levels:`, warning.message);
}
if (warning.type === 'depth_limit_exceeded') {
console.error(
`Schema depth exceeded at ${warning.depth} levels - using v.any()`,
warning.message
);
}
},
});When to Adjust:
- Increase maxDepth if you have legitimately deep schemas (e.g., nested configuration objects)
- Decrease maxDepth if experiencing slow type checking or memory issues
- Monitor warnings to identify problematic schemas before they cause issues
Recommendations:
- Keep schemas under 10 levels deep when possible
- Use composition and unions instead of deep nesting
- Consider flattening deeply nested structures
- Default 16 works for most real-world use cases (99%+ of schemas)
Type System Features
This package provides cutting-edge TypeScript patterns for enhanced type safety and developer experience. All features are 100% backward compatible with opt-in enhancements.
Const Type Parameters (Automatic)
Const generics preserve literal types through the conversion pipeline, providing better autocomplete and stricter type checking without any code changes.
import { zodToConvex } from '@libar-dev/zod-convex-core';
// Literal types are automatically preserved
const RoleSchema = z.object({
role: z.literal('admin'),
permissions: z.enum(['read', 'write', 'delete'] as const),
});
const validator = zodToConvex(RoleSchema);
// role is preserved as 'admin', permissions as 'read' | 'write' | 'delete' ✨
// Better autocomplete for discriminated unions
const EventSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('click'), x: z.number() }),
z.object({ type: z.literal('keypress'), key: z.string() }),
]);
// TypeScript now narrows correctly based on 'type' fieldLearn More: ADR 001: Const Type Parameters
Branded Types (Opt-In)
Branded types prevent accidental mixing of semantically different strings at compile time with zero runtime cost.
Package-Specific Branded Types
import {
type CodecId,
type ValidatorPath,
asCodecId,
asValidatorPath,
} from '@libar-dev/zod-convex-core';
// Type-safe codec IDs
const codecId: CodecId = asCodecId('date-codec');
const path: ValidatorPath = asValidatorPath('user.email');
// Can't accidentally mix types
processCodec(path); // ✅ Type error! ValidatorPath not assignable to CodecId
processCodec(codecId); // ✅ WorksGeneric Branded Type Utility
Create custom branded types for your own use cases:
import { brand, unbrand } from '@libar-dev/zod-convex-core';
// Define custom branded types
type UserId = string & { __brand: 'UserId' };
type SessionId = string & { __brand: 'SessionId' };
type Timestamp = number & { __brand: 'Timestamp' };
// Create branded values
const userId = brand<string, 'UserId'>('user_12345');
const sessionId = brand<string, 'SessionId'>('session_abc');
const timestamp = brand<number, 'Timestamp'>(Date.now());
// Type safety prevents mixing
const wrong: UserId = sessionId; // ✅ Type error!
// Unwrap when needed (logging, API boundaries)
console.log(`User: ${unbrand(userId)}`);
// Recommended pattern: Create helper functions
const asUserId = (id: string): UserId => brand<string, 'UserId'>(id);
const asSessionId = (id: string): SessionId => brand<string, 'SessionId'>(id);Benefits:
- Compile-time safety (catches bugs before runtime)
- Self-documenting code (clear type semantics)
- Better IDE support (autocomplete, type narrowing)
- Zero runtime overhead (brands erased at compile time)
Learn More: ADR 002: Branded Types
Strict Mode Helpers (Opt-In)
Security-first schema creation that rejects unknown fields by default, preventing data pollution from external sources.
import { createStrictSchema, ensureStrict } from '@libar-dev/zod-convex-core';
// ✅ Recommended: Use createStrictSchema() as default
const UserSchema = createStrictSchema({
name: z.string(),
email: z.string().email(),
});
// Rejects unknown fields (security benefit)
UserSchema.parse({
name: 'Alice',
email: '[email protected]',
malicious: '<script>xss</script>', // ✅ Rejected!
});
// Throws: Unrecognized key(s) in object: 'malicious'
// For existing schemas
const ExistingSchema = z.object({...});
const StrictSchema = ensureStrict(ExistingSchema);
// Aligns with security best practices:
// - Prevents data pollution from AI providers
// - Blocks malicious input (XSS, injection)
// - Enforces explicit field acceptanceLearn More: ADR 003: Validation Schema Pattern
Enhanced Codec Management
This package provides full lifecycle management for custom codecs with branded IDs.
import {
registerBaseCodec,
unregisterCodec,
getCodecById,
getAllCodecIds,
type BaseCodec,
type CodecId,
} from '@libar-dev/zod-convex-core';
// Register codec with explicit ID
const codecId = registerBaseCodec({
check: schema => schema instanceof z.ZodBigInt,
toValidator: () => v.int64(),
fromConvex: value => BigInt(value),
toConvex: value => Number(value),
}, 'bigint-codec'); // Returns: CodecId
// Later: Unregister codec (useful for testing)
const removed = unregisterCodec(codecId);
console.log(removed); // true
// Inspect registered codecs
const codec = getCodecById(codecId); // Get codec by ID
const allIds = getAllCodecIds(); // List all registered codecs
// Example: Test cleanup
describe('Codec Tests', () => {
let testCodecId: CodecId;
beforeEach(() => {
testCodecId = registerBaseCodec(testCodec, 'test-codec');
});
afterEach(() => {
unregisterCodec(testCodecId); // Clean up
});
});Features:
registerBaseCodec()returns aCodecIdfor lifecycle managementunregisterCodec(id)- Remove codec by IDgetCodecById(id)- Lookup codecgetAllCodecIds()- List all registered codecs- Codecs are checked in reverse registration order (last registered = highest priority)
Custom Type Registration
import { v } from 'convex/values';
import { z } from 'zod';
import { registerBaseCodec } from '@libar-dev/zod-convex-core';
// Register custom type conversions (example: BigInt support)
registerBaseCodec({
check: schema => schema instanceof z.ZodBigInt,
toValidator: () => v.int64(),
fromConvex: value => BigInt(value),
toConvex: value => Number(value),
});ID Validation
import { zid } from '@libar-dev/zod-convex-ids';
const schema = z.object({
userId: zid('users'), // Type-safe Convex ID
postId: zid('posts'), // Validates table name at type level
});Complex Type Handling
The converter handles advanced Zod patterns with full type safety:
Optional and Nullable Types
import { zodToConvex } from '@libar-dev/zod-convex-core';
import { z } from 'zod';
const ProfileSchema = z
.object({
bio: z.string().optional(), // Optional field
avatar: z.string().nullable(), // Nullable field
banner: z.string().optional().nullable(), // Both optional and nullable
})
.strict();
// Converted to Convex validators:
// bio: v.optional(v.string())
// avatar: v.union(v.string(), v.null())
// banner: v.optional(v.union(v.string(), v.null()))Conversion Rules:
.optional()→v.optional(...).nullable()→v.union(..., v.null()).optional().nullable()→v.optional(v.union(..., v.null()))
Note on Defaults: Zod defaults (.default()) are not preserved in Convex validators, as Convex
doesn't support default values. Handle defaults in your application logic.
Complex Optional Chains
The converter preserves optional semantics through nested structures:
const ComplexSchema = z
.object({
// Nested optional object
preferences: z
.object({
theme: z.string(),
notifications: z.boolean(),
})
.optional(),
// Optional array of objects
tags: z
.array(
z.object({
label: z.string(),
color: z.string().optional(),
})
)
.optional(),
// Nullable with nested structure
metadata: z
.object({
source: z.string(),
timestamp: z.number(),
})
.nullable(),
})
.strict();
// All optional/nullable semantics are preserved in conversion
const validator = zodToConvex(ComplexSchema);Record Types
z.record() is supported and converts to v.record(), but with important limitations:
// ✅ SUPPORTED: Record with string keys
const MetadataSchema = z.record(z.string(), z.number());
// Converts to: v.record(v.string(), v.float64())
// ✅ SUPPORTED: Records with optional values
const ConfigSchema = z.record(z.string(), z.string().optional());
// Converts to: v.record(v.string(), v.union(v.string(), v.null()))
// ⚠️ LIMITATION: Only string keys supported (Convex constraint)
const EnumKeyRecord = z.record(z.enum(['admin', 'user']), z.string());
// Falls back to: v.record(v.string(), v.any()) ⚠️
// ✅ RECOMMENDED: Prefer specific object schemas when keys are known
const StaticConfig = z
.object({
apiKey: z.string(),
endpoint: z.string(),
timeout: z.number(),
})
.strict();Key Limitations:
- String keys only - Convex
v.record()only acceptsv.string()keys (no enums, numbers, etc.) - Type safety - Static object schemas provide better TypeScript inference than dynamic records
- Performance - Specific object shapes are more efficient than dynamic records
When to Use Records:
- ✅ Truly dynamic keys unknown at compile time
- ✅ User-provided metadata or configuration
- ✅ Temporary data structures during processing
When to Use Objects:
- ✅ Keys known at schema definition time (vast majority of cases)
- ✅ Better type inference needed
- ✅ Optimal performance required
Nested Object Structures
The converter supports deep nesting with depth limit protection (default: 16 levels):
const DeepSchema = z
.object({
level1: z.object({
level2: z.object({
level3: z.object({
value: z.string(),
}),
}),
}),
})
.strict();
// Fully supported - converted to nested v.object() validators
const validator = zodToConvex(DeepSchema);Best Practice: Keep schemas under 10 levels deep when possible. Use composition and unions instead of excessive nesting.
Best Practices
Core Practices
- Default to
zodToConvex()- It handles most use cases automatically - Use
.strict()on schemas - Ensures no unexpected fields:z.object({...}).strict() - Leverage type inference - Let TypeScript infer types rather than declaring manually
- Hybrid tables for enums - Use native Convex validators for better enum type inference
- Single source of truth - Define schemas once in Zod, derive everything else
Enhanced Practices
Use
createStrictSchema()as default - Security-first schema creation// ✅ Recommended const UserSchema = createStrictSchema({ name: z.string() }); // Instead of: const UserSchema = z.object({ name: z.string() }).strict();Leverage const generics for literals - Automatic literal type preservation
const schema = z.object({ role: z.literal('admin'), // Preserved as 'admin' (not string) status: z.enum(['active', 'inactive'] as const), // Preserved as union });Register codecs before conversion - For optimal performance
const codecId = registerBaseCodec(myCodec, 'my-codec'); // Then convert schemas...Use branded types for type-safe IDs - Prevent string mixing bugs
const codecId: CodecId = asCodecId('date-codec'); // Can't accidentally pass wrong string typeSeparate structural from business validation - Security and maintainability
const Structural = createStrictSchema({ email: z.string() }); const BusinessRules = Structural.extend({}).refine(...);
See Also:
- Type System Features - Package capabilities
- Validation Schema Pattern (ADR 003) - Structural vs business rules
Supported Type Mappings
Complete mapping of Zod types to Convex validators:
| Zod Type | Convex Validator | Notes |
| ------------------ | ------------------------- | ------------------------------------------------------------------------- |
| z.string() | v.string() | Direct mapping |
| z.number() | v.float64() | All numbers as float64 |
| z.bigint() | v.int64() | BigInt support |
| z.boolean() | v.boolean() | Direct mapping |
| z.date() | v.float64() | Stored as Unix timestamp (use @libar-dev/zod-convex-codecs for conversion) |
| z.null() | v.null() | Direct mapping |
| z.undefined() | Not supported | Omit field instead |
| z.array(T) | v.array(T) | Recursive conversion |
| z.object({...}) | v.object({...}) | Field-by-field conversion |
| z.union([...]) | v.union(...) | All branches converted |
| z.enum([...]) | v.union(...) | Literals for each value |
| z.literal(value) | v.literal(value) | Direct mapping |
| z.optional(T) | v.optional(T) | Convex optional wrapper |
| z.nullable(T) | v.union(T, v.null()) | Union with null |
| z.record(K, V) | v.record(v.string(), V) | ✅ Supported (string keys only, prefer object schemas for known keys) |
| zid(table) | v.id(table) | Via @libar-dev/zod-convex-ids |
Known Limitations & Solutions
Enum Type Inference with TypeScript
Issue: When using zodToConvexFields() with z.enum() fields, TypeScript may incorrectly infer
the field types as undefined in the resulting DataModel, causing GenericDataModel constraint
errors.
Important: This is a TypeScript type inference limitation, NOT a runtime bug. The validators work correctly at runtime.
Solution: Use the Hybrid Approach for tables containing enum fields:
// ❌ Problem: Pure Zod approach fails TypeScript inference for enums
const Schema = z
.object({
userId: zid('users'),
role: z.enum(['admin', 'member', 'viewer']), // TypeScript infers as undefined
status: z.enum(['active', 'pending']), // TypeScript infers as undefined
})
.strict();
export const table = defineTable(
zodToConvexFields(Schema.shape) // Causes GenericDataModel errors!
);
// ✅ Solution: Hybrid approach - Zod for regular fields, Convex for enums
const BaseSchema = z
.object({
userId: zid('users'),
companyId: zid('companies'),
})
.strict();
export const table = defineTable({
...zodToConvexFields(BaseSchema.shape), // Zod for regular fields
role: v.union(
// Convex for enum fields
v.literal('admin'),
v.literal('member'),
v.literal('viewer')
),
status: v.union(v.literal('active'), v.literal('pending')),
}).index('by_user', ['userId']);Complex Unions with GenericDataModel
Challenge: Complex discriminated unions (20+ branches with nested zid()) may exceed
TypeScript's structural depth limits:
// ✅ Conversion SUCCEEDS
const EventPayloadSchema = z.union([
z.object({ type: z.literal('user.created'), userId: zid('users') }),
z.object({ type: z.literal('post.created'), postId: zid('posts') }),
// ... 20+ more schemas
]);
const validator = zodToConvex(EventPayloadSchema); // Works!
// ❌ But using in schema causes TypeScript errors
export default defineSchema({
events: defineTable({
payload: zodToConvex(EventPayloadSchema), // TypeScript error!
}),
});
// Error: Type 'DataModel' not assignable to 'GenericDataModel'Solution: Use validation-at-boundary pattern with v.any():
// 1. Schema: Use v.any() as storage contract
export default defineSchema({
events: defineTable({
// @architectural-directive: validation-at-boundary
// Complex union exceeds TypeScript limits, validated at write boundaries
payload: v.any(),
}),
});
// 2. Validation: Enforce at ALL write boundaries
export const createEvent = mutation({
args: zodToConvex(
z.object({
payload: EventPayloadSchema, // ← Full validation here
})
),
handler: async (ctx, args) => {
await ctx.db.insert('events', args);
},
});Migration from convex-helpers
Step-by-step guide to migrate from convex-helpers/server/zod:
Step 1: Update Dependencies
{
"dependencies": {
+ "@libar-dev/zod-convex-core": "^0.1.0",
+ "zod": "^4.1.0"
},
"devDependencies": {
- "convex-helpers": "^0.1.x" // Can keep for other features
}
}# Install new packages
npm install @libar-dev/zod-convex-core zod@^4.1.0
# Optional: Install related packages for enhanced features
npm install @libar-dev/zod-convex-ids # Type-safe ID validation
npm install @libar-dev/zod-convex-codecs # Date and custom type codecsStep 2: Update Imports
Before (convex-helpers):
import { zodToConvex, zid } from 'convex-helpers/server/zod';
import { z } from 'zod/v3'; // Zod v3 importAfter (@libar-dev/zod-convex-core):
import { zodToConvex } from '@libar-dev/zod-convex-core';
import { zid } from '@libar-dev/zod-convex-ids'; // Separate package
import { z } from 'zod'; // Zod v4 (no /v3 suffix)Step 3: Verify Build Order
IMPORTANT: Follow this build sequence to avoid type errors:
# 1. Build packages (if in monorepo)
npm run build:packages
# 2. Generate Convex types
npx convex codegen
# 3. Validate TypeScript
npm run check:fast
# or: npx tsc --noEmitStep 4: Leverage Zod v4 Features
// Zod v4: Native .brand() support
const UserIdSchema = z.string().brand('UserId');
// Zod v4: .unwrap() on optional/nullable
const schema = z.string().optional();
const inner = schema.unwrap(); // Returns z.string()
// Zod v4: Improved discriminated unions
const EventSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('click'), x: z.number(), y: z.number() }),
z.object({ type: z.literal('keypress'), key: z.string() }),
]);Step 5: Handle Enum Fields
If you encounter TypeScript errors with enum fields:
// Use the hybrid approach documented above
const BaseSchema = z.object({
// non-enum fields
});
export const table = defineTable({
...zodToConvexFields(BaseSchema.shape),
enumField: v.union(v.literal('opt1'), v.literal('opt2')),
});Comparison with convex-helpers
| Feature | convex-helpers | @libar-dev/zod-convex-core |
| ------------------------- | --------------------- | ------------------------------ |
| Zod Version | v3 only (zod/v3) | v4 native |
| ID Validation | Basic zid() | Separate package with metadata |
| Depth Protection | None | Configurable limits |
| Custom Type Registry | Limited | registerBaseCodec() |
| Optional Handling | Basic | Enhanced semantics |
| Brand Support | Custom implementation | Native Zod v4 .brand() |
| Enum Type Inference | Works | Hybrid approach needed |
| Complex Union Support | Limited | Validation-at-boundary pattern |
Related Packages
@libar-dev/zod-convex-ids- Type-safe ID validation@libar-dev/zod-convex-codecs- Date and custom type codecs@libar-dev/zod-convex-builders- Function builders@libar-dev/zod-convex-tables- Table utilities
License
MIT License - See LICENSE file for details.
Copyright (c) 2025 Libar AI
