@libar-dev/zod-convex-ids
v0.1.0
Published
Lightweight ID validation for Convex with Zod v4
Downloads
22
Maintainers
Readme
@libar-dev/zod-convex-ids
Type-safe ID validation for Convex tables using Zod v4 with enhanced type tracking.
Why This Package?
Background
This package provides the foundational ID validation system for the @libar-dev/zod-convex ecosystem. While convex-helpers provides basic zid() functionality for Zod v3, this package extends it with enhanced features for Zod v4 compatibility and type-level tracking.
What This Package Provides
✅ Type-Level Table Name Tracking - _tableName property for compile-time validation
✅ Zod v4 Native Support - Uses .brand() for type-safe ID validation
✅ Metadata Registry - WeakMap-based registry for runtime introspection
✅ ID Validator Factory - Generate typed validators from table mappings
✅ Zero Dependencies - Lightweight with only peer dependencies (zod, convex)
Compatibility Note
The zid() API is compatible with convex-helpers while adding Zod v4 support and enhanced type tracking. If you're migrating from convex-helpers, the API remains familiar.
Installation
npm install @libar-dev/zod-convex-ids zod@^4.1.0Peer Dependencies:
convex>=1.30.0 <2.0.0zod^4.1.0
Quick Start
Basic ID Validation
import { zid } from '@libar-dev/zod-convex-ids';
import { z } from 'zod';
// Create ID validator for 'users' table
const UserSchema = z.object({
name: z.string(),
email: z.string().email(),
organizationId: zid('organizations'), // Type-safe ID reference
});
// TypeScript knows organizationId is Id<'organizations'>
type User = z.infer<typeof UserSchema>;ID Validator Factory
Create reusable ID validators for your entire schema:
import { createIdValidators } from '@libar-dev/zod-convex-ids';
// Define your table mappings
const ids = createIdValidators({
user: 'users',
company: 'companies',
file: 'files',
invoice: 'invoices',
} as const);
// Use generated validators
const InvoiceSchema = z.object({
amount: z.number(),
userId: ids.userId(), // Returns Zid<'users'>
companyId: ids.companyId(), // Returns Zid<'companies'>
fileId: ids.fileId(), // Returns Zid<'files'>
}).strict();
// In mutations with zod-convex-core
import { zodToConvex } from '@libar-dev/zod-convex-core';
import { mutation } from './_generated/server';
export const createInvoice = mutation({
args: zodToConvex(InvoiceSchema),
handler: async (ctx, args) => {
// args.userId is properly typed as Id<'users'>
const user = await ctx.db.get(args.userId);
// TypeScript prevents ID type mismatches
return await ctx.db.insert('invoices', args);
}
});Common Chaining Patterns
ID validators support all standard Zod methods:
import { createIdValidators } from '@libar-dev/zod-convex-ids';
import { z } from 'zod';
const ids = createIdValidators({
user: 'users',
invoice: 'invoices',
receipt: 'receipts',
tag: 'tags',
} as const);
// Optional IDs
const TaskSchema = z.object({
title: z.string(),
assigneeId: ids.userId().optional(), // Id<'users'> | undefined
approvedBy: ids.userId().nullable(), // Id<'users'> | null
});
// Arrays of IDs
const ProjectSchema = z.object({
name: z.string(),
memberIds: z.array(ids.userId()), // Id<'users'>[]
tagIds: z.array(ids.tagId()).optional(), // Id<'tags'>[] | undefined
});
// Union types for polymorphic references
const PaymentSchema = z.object({
amount: z.number(),
documentId: z.union([
ids.invoiceId(),
ids.receiptId(),
]), // Id<'invoices'> | Id<'receipts'>
});API Documentation
Core Functions
zid(tableName)
Creates a type-safe Convex ID validator using Zod v4's .brand() method.
import { zid } from '@libar-dev/zod-convex-ids';
// Create ID validator
const userIdSchema = zid('users');
// TypeScript infers: z.ZodType<Id<'users'>>
type UserId = z.infer<typeof userIdSchema>;
// Runtime validation
const result = userIdSchema.safeParse('user_123abc');
if (result.success) {
// result.data is Id<'users'>
const user = await ctx.db.get(result.data);
}Key Features:
- Returns
z.ZodType<Id<TableName>>with_tableNameproperty - Uses Zod v4's native
.brand()for type safety - Stores metadata in WeakMap registry
- Provides
.describe()annotation:convexId:tableName
createIdValidators(mapping)
Creates a factory object with ID validators for each table.
const ids = createIdValidators({
user: 'users',
post: 'posts',
comment: 'comments',
} as const);
// Usage
const CommentSchema = z.object({
text: z.string(),
authorId: ids.userId(), // Zid<'users'>
postId: ids.postId(), // Zid<'posts'>
parentId: ids.commentId().optional(), // Zid<'comments'> | undefined
});Type Utilities
import type { Zid, IdValidators } from '@libar-dev/zod-convex-ids';
// Zid<T> represents the return type of zid()
type UserIdValidator = Zid<'users'>;
type PostIdValidator = Zid<'posts'>;
// IdValidators type for factory results
type MyIds = IdValidators<{
user: 'users';
post: 'posts';
}>;Registry Helpers
Access metadata about ID validators:
import { registryHelpers } from '@libar-dev/zod-convex-ids';
const userIdSchema = zid('users');
// Get metadata
const metadata = registryHelpers.getMetadata(userIdSchema);
// { isConvexId: true, tableName: 'users' }
// Check if schema is a Convex ID validator
const isId = registryHelpers.isConvexId(userIdSchema); // true
const isId2 = registryHelpers.isConvexId(z.string()); // falseType Safety Benefits
The zid() function provides compile-time and runtime safety:
// Compile-time table name tracking
function processUser(userId: Id<'users'>) {
// TypeScript enforces correct table reference
}
function processPost(postId: Id<'posts'>) {
// Different ID type - not interchangeable
}
const schema = z.object({
userId: zid('users'),
postId: zid('posts'),
});
export const linkUserToPost = mutation({
args: zodToConvex(schema),
handler: async (ctx, args) => {
// TypeScript prevents this mistake:
// const user = await ctx.db.get(args.postId); // ❌ Type error!
// Correct usage:
const user = await ctx.db.get(args.userId); // ✅
const post = await ctx.db.get(args.postId); // ✅
}
});Integration Patterns
With Table Definitions
import { zid } from '@libar-dev/zod-convex-ids';
import { zodToConvex } from '@libar-dev/zod-convex-core';
import { defineTable } from 'convex/server';
const PostSchema = z.object({
title: z.string(),
content: z.string(),
authorId: zid('users'),
categoryId: zid('categories').optional(),
});
export const posts = defineTable(zodToConvex(PostSchema))
.index('by_author', ['authorId'])
.index('by_category', ['categoryId']);With Complex Schemas
// Nested ID references
const CommentSchema = z.object({
text: z.string(),
authorId: zid('users'),
postId: zid('posts'),
parentId: zid('comments').nullable(), // Self-reference
mentionedUserIds: z.array(zid('users')), // Array of IDs
});
// Union types with IDs
const NotificationSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('comment'),
commentId: zid('comments'),
userId: zid('users'),
}),
z.object({
type: z.literal('like'),
postId: zid('posts'),
userId: zid('users'),
}),
]);Integration with Generated Validators
When using @libar-dev/zod-convex-gen for build-time validator generation, zid() types are preserved and converted to v.id() validators automatically.
How It Works
The generator introspects Zod schemas and converts zid() calls to their Convex equivalents:
// Source Zod Schema (src/table-schemas/posts.ts)
import { z } from 'zod';
import { zid } from '@libar-dev/zod-convex-ids';
export const PostsTableSchema = z.object({
authorId: zid('users'), // Type-safe ID validation
categoryId: zid('categories'),
title: z.string(),
content: z.string(),
}).strict();// Generated Convex Validator (convex/generatedValidators/posts.ts)
import { v } from 'convex/values';
export const postsFields = {
authorId: v.id('users'), // Converted to Convex ID validator
categoryId: v.id('categories'),
title: v.string(),
content: v.string(),
} as const;Type Preservation
The table name from zid('tableName') is preserved during generation:
- Compile-time: TypeScript tracks table names via
Zid<'tableName'>branded type - Generation-time: Generator extracts table name and creates
v.id('tableName') - Runtime: Convex validates IDs against the specified table
Usage Example
// 1. Define schema with zid() (outside Convex bundle)
// src/table-schemas/comments.ts
export const CommentsTableSchema = z.object({
postId: zid('posts'),
authorId: zid('users'),
text: z.string(),
}).strict();
// 2. Generate validators (build-time)
// npx zod-convex-gen
// 3. Use generated validators (zero Zod in bundle)
// convex/schema.ts
import { commentsFields } from './generatedValidators/comments';
export default defineSchema({
comments: defineTable(commentsFields)
.index('by_post', ['postId'])
.index('by_author', ['authorId'])
});Memory Impact
Using zid() with generated validators provides the best of both worlds:
- ✅ Type safety from
Zid<'tableName'>branded types - ✅ Reduced memory overhead - Zod not included in Convex bundle
- ✅ Runtime validation - Convex validates table names at runtime
- ✅ Significant memory reduction compared to runtime Zod (75-83% decrease in tested applications)
Related Package
See @libar-dev/zod-convex-gen for complete build-time generation documentation and workflow examples.
Build-Time Schema Pattern (Local Factory)
When creating build-time schemas in src/validation-schemas/ or src/table-schemas/ that will be processed by @libar-dev/zod-convex-gen, you cannot import from the main application's convex/ directory. Instead, create a local ID validator factory within the schema file:
Pattern:
// src/validation-schemas/processors/financial/camt053Processor.ts
import { z } from 'zod';
import { createIdValidators } from '@libar-dev/zod-convex-ids';
// Create local factory for build-time generation
const ids = createIdValidators({
company: 'companies',
file: 'files',
userMessage: 'aiChatMessages',
businessAccount: 'businessAccounts',
} as const);
export const Camt053ArgsSchema = z.object({
companyId: ids.companyId(),
fileId: ids.fileId(),
userMessageId: ids.userMessageId().optional(),
}).strict();Why Local Factory:
- Build-time schemas execute outside the main app context
- Generator cannot resolve imports from
convex/directory - Local factory provides same type safety as application factory
- Generated validators are identical (
v.id('aiChatMessages'))
When to Use:
src/validation-schemas/- Build-time function argument schemassrc/table-schemas/- Build-time table schemas (if using generator)
When NOT to Use:
convex/directory code - Use application factory (import { ids } from '../toolkit/validation/idValidators')- Runtime validation - Application factory is always available
Generated Output:
The generator extracts table names from both patterns identically:
// Generated: convex/generatedValidators/camt053Processor.ts
export const camt053ArgsFields = {
userMessageId: v.optional(v.id('aiChatMessages')) // Identical to application factory
} as const;Pattern Comparison:
| Location | Pattern | Import From |
|----------|---------|-------------|
| convex/ directory | Application Factory | import { ids } from '../toolkit/validation/idValidators' |
| src/validation-schemas/ | Local Factory | import { createIdValidators } from '@libar-dev/zod-convex-ids' |
| src/table-schemas/ | Local Factory | import { createIdValidators } from '@libar-dev/zod-convex-ids' |
Known Limitations
Test Environment Behavior
The zid() validator performs minimal validation in test environments:
// In tests (outside Convex runtime)
const schema = z.object({ userId: zid('users') });
const result = schema.safeParse({ userId: 'any-string' });
// result.success = true (accepts ANY string in tests)
// In Convex runtime
// Full validation is performed (table name and ID format)Why This Design Decision?
This behavior is intentional to simplify testing:
- ✅ No need to generate valid Convex IDs in test data
- ✅ Tests focus on business logic, not ID format
- ✅ Faster test execution (no ID validation overhead)
- ❌ Runtime validation still enforced in production
When This Matters:
During testing, you can use any string format for IDs:
// All these work in tests:
schema.safeParse({ userId: 'test-user' }); // ✅ Passes
schema.safeParse({ userId: '12345' }); // ✅ Passes
schema.safeParse({ userId: 'abc-def-ghi' }); // ✅ Passes
// In Convex runtime, only valid IDs work:
// { userId: 'k17abc123def456' } // ✅ Valid Convex ID format
// { userId: 'test-user' } // ❌ Rejected by ConvexBest Practices for Test Data:
While any string works, use realistic ID formats for clarity:
// ✅ RECOMMENDED: Numeric format (matches common patterns)
const testUserId = '12345;users' as Id<'users'>;
const testPostId = '67890;posts' as Id<'posts'>;
// ✅ ALTERNATIVE: Descriptive names (easier to debug)
const testUserId = 'test-user-123' as Id<'users'>;
// ⚠️ AVOID: Confusing formats
const testUserId = 'x' as Id<'users'>; // Too short, unclear in logsKey Insight: The test behavior only affects Zod's .safeParse() validation. Convex's runtime ID validation is always enforced when data enters the database.
Workflow Arguments
Convex workflows require v.id() validators, not Zod schemas:
// ❌ Workflows don't support Zod directly
export const myWorkflow = workflow({
args: { userId: zid('users') }, // Won't work
});
// ✅ Use v.id() for workflows
export const myWorkflow = workflow({
args: { userId: v.id('users') },
});
// ✅ Use Zod for regular mutations/queries
export const myMutation = mutation({
args: zodToConvex(z.object({ userId: zid('users') })),
});Migration from convex-helpers
The API is compatible with convex-helpers while adding Zod v4 support and enhanced features.
Step 1: Update Dependencies
{
"dependencies": {
+ "@libar-dev/zod-convex-ids": "^0.1.0",
+ "zod": "^4.1.0"
},
"devDependencies": {
- "convex-helpers": "^0.1.x" // Can keep for other features
}
}Step 2: Update Imports
Before (convex-helpers):
import { zid } from 'convex-helpers/server/zod';
import { z } from 'zod/v3'; // Zod v3After (@libar-dev/zod-convex-ids):
import { zid } from '@libar-dev/zod-convex-ids';
import { z } from 'zod'; // Zod v4 (no /v3 suffix)Step 3: Leverage Enhanced Features (Optional)
While the basic API is the same, you can now use additional features:
// Basic usage (same as convex-helpers)
const userId = zid('users');
// NEW: ID validator factory for consistency
import { createIdValidators } from '@libar-dev/zod-convex-ids';
const ids = createIdValidators({
user: 'users',
post: 'posts',
comment: 'comments',
} as const);
// Use factory-generated validators
const CommentSchema = z.object({
text: z.string(),
authorId: ids.userId(), // Type-safe, consistent naming
postId: ids.postId(),
});What's Enhanced
Compared to convex-helpers, this package adds:
- Zod v4 support - Uses native
.brand()for type safety - Type-level tracking -
_tableNameproperty for compile-time validation - Metadata registry - Runtime introspection via
registryHelpers - ID validator factories - Generate validators from table mappings
- Build-time generation - Works with
@libar-dev/zod-convex-genfor memory optimization
Related Packages
@libar-dev/zod-convex-core- Core conversion engine@libar-dev/zod-convex-codecs- Bidirectional 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
