npm package: @libar-dev/zod-convex-ids
Description: Lightweight ID validation for Convex with Zod v4
Author: Libar AI
Latest version: 0.1.0

# @libar-dev/zod-convex-ids

[![npm version](https://img.shields.io/npm/v/@libar-dev/zod-convex-ids.svg)](https://www.npmjs.com/package/@libar-dev/zod-convex-ids)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.5-blue)](https://www.typescriptlang.org/)

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

```bash
npm install @libar-dev/zod-convex-ids zod@^4.1.0
```

**Peer Dependencies:**
- `convex` >=1.30.0 <2.0.0
- `zod` ^4.1.0

## Quick Start

### Basic ID Validation

```typescript
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:

```typescript
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:

```typescript
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.

```typescript
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 `_tableName` property
- 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.

```typescript
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

```typescript
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:

```typescript
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()); // false
```

## Type Safety Benefits

The `zid()` function provides compile-time and runtime safety:

```typescript
// 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

```typescript
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

```typescript
// 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:

```typescript
// 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();
```

```typescript
// 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

```typescript
// 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`](https://www.npmjs.com/package/@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:**
```typescript
// 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 schemas
- `src/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:
```typescript
// 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:

```typescript
// 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:

```typescript
// 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 Convex
```

**Best Practices for Test Data:**

While any string works, use realistic ID formats for clarity:

```typescript
// ✅ 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 logs
```

**Key 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:

```typescript
// ❌ 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

```diff
{
  "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):**
```typescript
import { zid } from 'convex-helpers/server/zod';
import { z } from 'zod/v3';  // Zod v3
```

**After (@libar-dev/zod-convex-ids):**
```typescript
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:

```typescript
// 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** - `_tableName` property 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-gen` for memory optimization

## Related Packages

- [`@libar-dev/zod-convex-core`](https://www.npmjs.com/package/@libar-dev/zod-convex-core) - Core conversion engine
- [`@libar-dev/zod-convex-codecs`](https://www.npmjs.com/package/@libar-dev/zod-convex-codecs) - Bidirectional codecs
- [`@libar-dev/zod-convex-builders`](https://www.npmjs.com/package/@libar-dev/zod-convex-builders) - Function builders
- [`@libar-dev/zod-convex-tables`](https://www.npmjs.com/package/@libar-dev/zod-convex-tables) - Table utilities

## License

MIT License - See [LICENSE](./LICENSE) file for details.

Copyright (c) 2025 Libar AI