npm package: @libar-dev/zod-convex-codecs
Description: Bidirectional codecs for Date and custom type conversion with Convex
Author: Libar AI
Latest version: 0.1.0

# @libar-dev/zod-convex-codecs

[![npm version](https://img.shields.io/npm/v/@libar-dev/zod-convex-codecs.svg)](https://www.npmjs.com/package/@libar-dev/zod-convex-codecs)
[![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/)

Bidirectional codecs for Date and custom type conversion between Zod schemas and Convex storage
format.

## Why This Package?

This package provides automatic encoding/decoding for complex types when working with Convex:

- **Automatic Date Conversion** - Seamlessly convert between JavaScript `Date` and Unix timestamps
- **Bidirectional Codecs** - Encode for storage, decode for retrieval
- **Custom Type Support** - Register your own type converters via `@libar-dev/zod-convex-core`
- **Schema-Aware** - Respects your Zod schema structure
- **Zero Configuration** - Date handling works out of the box

## Installation

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

**Peer Dependencies:**

- `convex` >= 1.30.0 < 2.0.0
- `zod` ^4.1.0

**Dependencies:**

- `@libar-dev/zod-convex-core` - Core conversion engine (includes ID validation support)

## Quick Start

### Automatic Date Conversion

```typescript
import { convexCodec } from '@libar-dev/zod-convex-codecs';
import { z } from 'zod';

// Define schema with Date types
const EventSchema = z.object({
  title: z.string(),
  eventDate: z.date(),
  createdAt: z.date(),
});

// Create bidirectional codec
const codec = convexCodec(EventSchema);

// Encode: JavaScript Date → Unix timestamp (for Convex storage)
const convexData = codec.encode({
  title: 'Team Meeting',
  eventDate: new Date('2024-01-15T10:00:00Z'),
  createdAt: new Date(),
});
// Result: { title: 'Team Meeting', eventDate: 1705316400000, createdAt: 1705316400000 }

// Decode: Unix timestamp → JavaScript Date (for application use)
const jsData = codec.decode(convexData);
// Result: { title: 'Team Meeting', eventDate: Date(2024-01-15), createdAt: Date }
```

### Use in Convex Functions

```typescript
import { convexCodec } from '@libar-dev/zod-convex-codecs';
import { mutation, query } from './_generated/server';
import { v } from 'convex/values';

const EventSchema = z.object({
  title: z.string(),
  startDate: z.date(),
  endDate: z.date(),
});

const eventCodec = convexCodec(EventSchema);

// Mutation: Dates automatically encoded to timestamps
export const createEvent = mutation({
  args: eventCodec.validator,
  handler: async (ctx, args) => {
    // args has Dates as timestamps (Convex-safe)
    const eventId = await ctx.db.insert('events', args);
    return eventId;
  },
});

// Query: Decode timestamps back to Dates for client
export const getEvent = query({
  args: { id: v.id('events') },
  handler: async (ctx, args) => {
    const event = await ctx.db.get(args.id);
    if (!event) return null;

    // Decode timestamps back to Date objects
    return eventCodec.decode(event);
  },
});

// Client receives proper Date objects
const event = await convex.query(api.events.getEvent, { id: eventId });
console.log(event.startDate instanceof Date); // true
```

## API Documentation

### `convexCodec(schema)`

Creates a bidirectional codec for encoding/decoding between JavaScript and Convex types.

```typescript
import { convexCodec } from '@libar-dev/zod-convex-codecs';

const UserSchema = z.object({
  name: z.string(),
  birthDate: z.date(),
  lastLogin: z.date().nullable(),
  metadata: z.object({
    createdAt: z.date(),
    updatedAt: z.date(),
  }),
});

const codec = convexCodec(UserSchema);

// Codec provides:
codec.validator; // Convex validator for the schema
codec.encode(); // Convert JS types to Convex types
codec.decode(); // Convert Convex types to JS types
codec.pick(); // Create codec for subset of fields
```

**Key Features:**

- Automatic Date ↔ timestamp conversion
- Handles nested objects and arrays
- Preserves nullable and optional semantics
- Type-safe encode/decode operations

### `toConvexJS(schema, value)` / `toConvexJS(value)`

Converts JavaScript values to Convex-safe JSON format.

```typescript
import { toConvexJS } from '@libar-dev/zod-convex-codecs';

// With schema (schema-aware conversion)
const UserSchema = z.object({
  name: z.string(),
  createdAt: z.date(),
});

const convexValue = toConvexJS(UserSchema, {
  name: 'Alice',
  createdAt: new Date('2024-01-15'),
});
// Result: { name: 'Alice', createdAt: 1705276800000 }

// Without schema (basic conversion)
const basicValue = toConvexJS({
  count: 42,
  timestamp: new Date(),
  items: ['a', 'b', 'c'],
  optional: undefined, // Will be removed
});
// Dates → timestamps, undefined → removed
```

**Conversion Rules:**

- `Date` objects → Unix timestamps (milliseconds)
- `undefined` values → removed from objects
- Nested objects/arrays → recursively converted

### `fromConvexJS(value, schema)`

Converts Convex JSON back to JavaScript types.

```typescript
import { fromConvexJS } from '@libar-dev/zod-convex-codecs';

const EventSchema = z.object({
  title: z.string(),
  eventDate: z.date(),
});

const convexData = {
  title: 'Meeting',
  eventDate: 1705276800000, // Unix timestamp from Convex
};

const jsData = fromConvexJS(convexData, EventSchema);
// Result: { title: 'Meeting', eventDate: new Date(2024-01-15) }
```

### Pick Subset of Fields

Create a codec for just specific fields:

```typescript
const FullSchema = z.object({
  id: z.string(),
  name: z.string(),
  createdAt: z.date(),
  updatedAt: z.date(),
  deletedAt: z.date().nullable(),
});

const fullCodec = convexCodec(FullSchema);

// Create codec for just date fields
const dateCodec = fullCodec.pick(['createdAt', 'updatedAt', 'deletedAt']);

const dates = dateCodec.encode({
  createdAt: new Date('2024-01-15'),
  updatedAt: new Date('2024-01-16'),
  deletedAt: null,
});
// { createdAt: 1705276800000, updatedAt: 1705363200000, deletedAt: null }
```

## Advanced Usage

### Complex Nested Schemas

The codec system handles deeply nested structures:

```typescript
const OrderSchema = z.object({
  orderId: z.string(),
  items: z.array(
    z.object({
      productId: zid('products'),
      quantity: z.number(),
      addedAt: z.date(),
    })
  ),
  totalAmount: z.number(),
  placedAt: z.date(),
  shippedAt: z.date().nullable(),
  metadata: z.object({
    createdBy: z.string(),
    timestamps: z.object({
      created: z.date(),
      modified: z.date(),
    }),
  }),
});

const orderCodec = convexCodec(OrderSchema);

// All Date fields at any nesting level are handled
const encoded = orderCodec.encode({
  orderId: 'ORD-123',
  items: [
    {
      productId: 'prod_abc123' as Id<'products'>,
      quantity: 2,
      addedAt: new Date(),
    },
  ],
  totalAmount: 49.99,
  placedAt: new Date(),
  shippedAt: null,
  metadata: {
    createdBy: 'user_123',
    timestamps: {
      created: new Date(),
      modified: new Date(),
    },
  },
});
// All Date objects converted to timestamps
```

### Custom Type Registration

Register custom type converters for specialized Zod types:

```typescript
import { registerBaseCodec } from '@libar-dev/zod-convex-core';
import { v } from 'convex/values';

// Example: BigInt support
registerBaseCodec({
  check: schema => schema instanceof z.ZodBigInt,
  toValidator: () => v.int64(),
  fromConvex: value => BigInt(value),
  toConvex: value => Number(value),
});

// Now BigInt fields work automatically with codecs
const schema = z.object({
  balance: z.bigint(),
});

const codec = convexCodec(schema);
```

### Codec-Based API Layer Pattern

Build a type-safe API layer with automatic conversion:

```typescript
// shared/schemas.ts
export const EventSchema = z.object({
  id: z.string(),
  title: z.string(),
  startDate: z.date(),
  endDate: z.date(),
  attendees: z.array(zid('users')),
});

// convex/events.ts
import { convexCodec } from '@libar-dev/zod-convex-codecs';
import { EventSchema } from '../shared/schemas';

const eventCodec = convexCodec(EventSchema);

// All mutations use the same codec
export const createEvent = mutation({
  args: eventCodec.validator,
  handler: async (ctx, args) => {
    const id = await ctx.db.insert('events', args);
    return id;
  },
});

export const updateEvent = mutation({
  args: {
    id: v.id('events'),
    data: eventCodec.validator,
  },
  handler: async (ctx, { id, data }) => {
    await ctx.db.patch(id, data);
  },
});

// Queries decode on return
export const listEvents = query({
  handler: async ctx => {
    const events = await ctx.db.query('events').collect();
    return events.map(event => eventCodec.decode(event));
  },
});
```

## Working with Dates

### Date Handling Details

Dates are automatically converted between JavaScript `Date` objects and Unix timestamps:

```typescript
// Encoding (JS → Convex)
const encoded = codec.encode({ eventDate: new Date('2024-01-15T10:00:00Z') });
// { eventDate: 1705316400000 }

// Decoding (Convex → JS)
const decoded = codec.decode({ eventDate: 1705316400000 });
// { eventDate: Date object }
```

### Nullable and Optional Dates

The codec system preserves nullable and optional semantics:

```typescript
const Schema = z.object({
  requiredDate: z.date(),
  optionalDate: z.date().optional(),
  nullableDate: z.date().nullable(),
  optionalNullableDate: z.date().nullable().optional(),
});

const codec = convexCodec(Schema);

// All variations work correctly
const encoded = codec.encode({
  requiredDate: new Date(),
  optionalDate: undefined,
  nullableDate: null,
  // optionalNullableDate omitted
});
```

### Arrays of Dates

Arrays and nested arrays are fully supported:

```typescript
const TimeSeriesSchema = z.object({
  timestamps: z.array(z.date()),
  nestedDates: z.array(
    z.object({
      start: z.date(),
      end: z.date(),
    })
  ),
});

const codec = convexCodec(TimeSeriesSchema);
// All dates in arrays are converted
```

## Codec System Internals

### Codec Registry

The codec system uses a registry pattern for extensibility:

```typescript
// Built-in codec for Date
const dateCodec = {
  check: schema => isDateSchema(schema),
  toValidator: () => v.float64(),
  fromConvex: value => new Date(value),
  toConvex: value => value.getTime(),
};
```

### Lookup Order

When processing a schema, the codec system:

1. Checks registered custom codecs (via `registerBaseCodec`)
2. Checks built-in Date codec
3. Falls back to recursive structural conversion

### Performance Considerations

- Codecs are created once and reused
- Encode/decode operations are synchronous
- No schema parsing on each operation
- Minimal overhead for non-Date types

## Migration from Manual Conversion

If you've been manually converting between JavaScript types (like `Date`) and Convex storage formats, this guide shows how to migrate to the codec system.

### Before (Manual Conversion)

```typescript
// Manual: Boilerplate-heavy approach
export const createEvent = mutation({
  args: {
    title: v.string(),
    startDate: v.float64(), // Manually specify timestamp type
    endDate: v.float64(),
    reminders: v.array(v.float64()), // Array of timestamps
  },
  handler: async (ctx, args) => {
    // Manual conversion if needed for business logic
    const startDate = new Date(args.startDate);
    const endDate = new Date(args.endDate);

    // Validate business rules
    if (startDate >= endDate) {
      throw new Error('Start date must be before end date');
    }

    await ctx.db.insert('events', args);
  },
});

export const getEvent = query({
  handler: async ctx => {
    const event = await ctx.db.query('events').first();
    if (!event) return null;

    // Manual conversion for client consumption
    return {
      ...event,
      startDate: new Date(event.startDate),
      endDate: new Date(event.endDate),
      reminders: event.reminders.map(ts => new Date(ts)),
    };
  },
});
```

### After (With Codecs)

```typescript
// With codecs: Clean, type-safe, automatic
const EventSchema = z.object({
  title: z.string(),
  startDate: z.date(),
  endDate: z.date(),
  reminders: z.array(z.date()),
});

const eventCodec = convexCodec(EventSchema);

export const createEvent = mutation({
  args: eventCodec.validator,
  handler: async (ctx, args) => {
    // args already encoded (dates as timestamps)
    // Business logic can decode if needed
    const decoded = eventCodec.decode(args);
    if (decoded.startDate >= decoded.endDate) {
      throw new Error('Start date must be before end date');
    }

    await ctx.db.insert('events', args);
  },
});

export const getEvent = query({
  handler: async ctx => {
    const event = await ctx.db.query('events').first();
    // Automatic decode handles all nested dates
    return event ? eventCodec.decode(event) : null;
  },
});
```

### Benefits of Migration

| Aspect | Manual Approach | Codec Approach |
|--------|----------------|----------------|
| Type Safety | Manual type alignment | Automatic from Zod schema |
| Date Conversion | Manual in each function | Automatic encode/decode |
| Nested Dates | Manual recursive handling | Automatic any depth |
| Consistency | Easy to miss fields | Schema is single source |
| Refactoring | Update every function | Update schema only |
| Type Inference | Manual type definitions | Automatic inference |

### Step-by-Step Migration

1. **Define your schema with Zod:**
   ```typescript
   const EventSchema = z.object({
     title: z.string(),
     startDate: z.date(),  // Use z.date() instead of z.number()
     endDate: z.date(),
   });
   ```

2. **Create the codec:**
   ```typescript
   import { convexCodec } from '@libar-dev/zod-convex-codecs';
   const eventCodec = convexCodec(EventSchema);
   ```

3. **Update mutation args:**
   ```typescript
   // Before
   args: { title: v.string(), startDate: v.float64() }

   // After
   args: eventCodec.validator
   ```

4. **Update query returns:**
   ```typescript
   // Before
   return { ...event, startDate: new Date(event.startDate) };

   // After
   return eventCodec.decode(event);
   ```

### Common Migration Patterns

**Pattern 1: Partial schema with pick**

```typescript
const fullCodec = convexCodec(FullEventSchema);

// Create codec for just the date fields
const datesCodec = fullCodec.pick(['startDate', 'endDate']);
```

**Pattern 2: Gradual migration**

```typescript
// Keep manual for some fields, use codec for dates
const createEvent = mutation({
  args: {
    title: v.string(),               // Keep manual
    ...eventCodec.pick(['startDate', 'endDate']).validator,  // Migrate dates
  },
  handler: async (ctx, args) => { /* ... */ },
});
```

**Pattern 3: Shared codecs across files**

```typescript
// shared/codecs.ts
export const eventCodec = convexCodec(EventSchema);
export const userCodec = convexCodec(UserSchema);

// convex/events.ts
import { eventCodec } from '../shared/codecs';
```

### Comparison Table

| Task | Manual Code | With Codecs |
|------|-------------|-------------|
| Define Date field | `v.float64()` | `z.date()` in schema |
| Convert Date → timestamp | `date.getTime()` | `codec.encode()` (auto) |
| Convert timestamp → Date | `new Date(ts)` | `codec.decode()` (auto) |
| Handle nested dates | Loop/map manually | Automatic |
| Handle arrays of dates | `.map(ts => new Date(ts))` | Automatic |
| Handle nullable dates | `ts ? new Date(ts) : null` | Automatic |
| Partial updates | Manual field selection | `codec.pick([...])` |

## Type Safety

The codec system maintains full TypeScript type inference:

```typescript
const Schema = z.object({
  name: z.string(),
  birthDate: z.date(),
});

const codec = convexCodec(Schema);

// TypeScript knows the types
type EncodedType = ReturnType<typeof codec.encode>;
// { name: string; birthDate: number }

type DecodedType = ReturnType<typeof codec.decode>;
// { name: string; birthDate: Date }
```

## Related Packages

- [`@libar-dev/zod-convex-core`](https://www.npmjs.com/package/@libar-dev/zod-convex-core) - Core conversion
  engine
- [`@libar-dev/zod-convex-ids`](https://www.npmjs.com/package/@libar-dev/zod-convex-ids) - Type-safe ID
  validation
- [`@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