npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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+)

Readme

@libar-dev/zod-convex-core

npm version License: MIT TypeScript

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@^4

Basic 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

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-helpers pins 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 validation
  • z.url() - URL validation

Identifiers:

  • z.uuid() - Strict RFC 9562/4122 UUID validation
  • z.guid() - Permissive GUID (8-4-4-4-12 pattern)
  • z.jwt() - JWT token format

Network:

  • z.ipv4() - IPv4 address
  • z.ipv6() - IPv6 address
  • z.cidrv4() - IPv4 CIDR notation
  • z.cidrv6() - IPv6 CIDR notation

Encoding:

  • z.base64() - Base64 encoding
  • z.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 datetime
  • z.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.0

Peer Dependencies:

  • convex >= 1.30.0 < 2.0.0
  • zod ^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 (CreateUserArgsSchemaCreateUserArgs)
  • Table schemas: Use singular form (UsersTableSchemaUser)

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-gen with type aliases (90% memory reduction)
  • Development/prototyping: Use runtime zodToConvex() directly (simpler, no build step)

See Also:

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:

  1. Table schemas → Use structural validation only (types, optionality, unions)
  2. Function arguments → Apply .refine() and business logic validation
  3. Custom types → Register codecs via @libar-dev/zod-convex-codecs package
  4. Date handling → Built-in codec automatically converts Date ↔ timestamp

See Also:

Migration Path

When you're ready to optimize for production:

Step 1: Install Generator

npm install --save-dev @libar-dev/zod-convex-gen

Step 2: Generate Validators

npx zod-convex-gen

Step 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

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' field

Learn 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); // ✅ Works
Generic 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 acceptance

Learn 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 a CodecId for lifecycle management
  • unregisterCodec(id) - Remove codec by ID
  • getCodecById(id) - Lookup codec
  • getAllCodecIds() - 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 accepts v.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

  1. Default to zodToConvex() - It handles most use cases automatically
  2. Use .strict() on schemas - Ensures no unexpected fields: z.object({...}).strict()
  3. Leverage type inference - Let TypeScript infer types rather than declaring manually
  4. Hybrid tables for enums - Use native Convex validators for better enum type inference
  5. Single source of truth - Define schemas once in Zod, derive everything else

Enhanced Practices

  1. 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();
  2. 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
    });
  3. Register codecs before conversion - For optimal performance

    const codecId = registerBaseCodec(myCodec, 'my-codec');
    // Then convert schemas...
  4. Use branded types for type-safe IDs - Prevent string mixing bugs

    const codecId: CodecId = asCodecId('date-codec');
    // Can't accidentally pass wrong string type
  5. Separate structural from business validation - Security and maintainability

    const Structural = createStrictSchema({ email: z.string() });
    const BusinessRules = Structural.extend({}).refine(...);

See Also:

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 codecs

Step 2: Update Imports

Before (convex-helpers):

import { zodToConvex, zid } from 'convex-helpers/server/zod';
import { z } from 'zod/v3'; // Zod v3 import

After (@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 --noEmit

Step 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

License

MIT License - See LICENSE file for details.

Copyright (c) 2025 Libar AI