Agent skill

convex-schema-validators

Guide for Convex schema design, validators, and TypeScript types. Use when defining database schemas, creating validators for function arguments/returns, working with document types, or ensuring type safety. Activates for schema.ts creation, validator usage, Id/Doc type handling, or TypeScript integration tasks.

Stars 16
Forks 0

Install this agent skill to your Project

npx add-skill https://github.com/fluid-tools/claude-skills/tree/main/.claude/skills/convex-schema-validators

SKILL.md

Convex Schema & Validators Guide

Overview

Convex uses a schema-first approach with built-in validators for type safety. This skill covers schema design, validator patterns, TypeScript type integration, and best practices for type-safe Convex development.

TypeScript: NEVER Use any Type

CRITICAL RULE: This codebase has @typescript-eslint/no-explicit-any enabled. Using any will cause build failures.

❌ WRONG:

typescript
const data: any = await ctx.db.get(id);
function process(items: any[]) { ... }

✅ CORRECT:

typescript
const data: Doc<"users"> | null = await ctx.db.get(id);
function process(items: Doc<"items">[]) { ... }

When to Use This Skill

Use this skill when:

  • Creating or modifying convex/schema.ts
  • Defining validators for function arguments and returns
  • Working with document IDs and types
  • Setting up indexes for efficient queries
  • Handling optional fields and unions
  • Integrating Convex types with TypeScript

Schema Definition

Basic Schema Structure

typescript
// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  users: defineTable({
    name: v.string(),
    email: v.string(),
    avatarUrl: v.optional(v.string()),
    role: v.union(v.literal("admin"), v.literal("user")),
    createdAt: v.number(),
  })
    .index("by_email", ["email"])
    .index("by_role", ["role"]),

  messages: defineTable({
    authorId: v.id("users"),
    channelId: v.id("channels"),
    content: v.string(),
    isDeleted: v.boolean(),
  })
    .index("by_channel", ["channelId"])
    .index("by_author", ["authorId"])
    .index("by_channel_author", ["channelId", "authorId"]),

  channels: defineTable({
    name: v.string(),
    members: v.array(v.id("users")),
    isPrivate: v.boolean(),
  }),
});

Table Definition Patterns

typescript
// defineTable takes a validator object
defineTable({
  field1: v.string(),
  field2: v.number(),
});

// Chain indexes after defineTable
defineTable({
  userId: v.id("users"),
  status: v.string(),
})
  .index("by_user", ["userId"])
  .index("by_status", ["status"])
  .index("by_user_status", ["userId", "status"]);

// Search indexes for full-text search
defineTable({
  title: v.string(),
  body: v.string(),
}).searchIndex("search_body", {
  searchField: "body",
  filterFields: ["title"],
});

Validator Reference

Primitive Validators

typescript
import { v } from "convex/values";

v.string(); // string
v.number(); // number (float64)
v.boolean(); // boolean
v.null(); // null literal
v.int64(); // 64-bit integer (NOT v.bigint() - deprecated!)
v.bytes(); // ArrayBuffer

Complex Validators

typescript
// Document IDs
v.id("tableName"); // Id<"tableName">

// Arrays
v.array(v.string()); // string[]
v.array(v.id("users")); // Id<"users">[]
v.array(v.object({ x: v.number() })); // { x: number }[]

// Objects
v.object({
  name: v.string(),
  age: v.number(),
  email: v.optional(v.string()),
});

// Records (string keys, typed values)
v.record(v.string(), v.number()); // Record<string, number>
v.record(v.id("users"), v.string()); // Record<Id<"users">, string>

// Unions (OR types)
v.union(v.string(), v.null()); // string | null
v.union(v.literal("a"), v.literal("b")); // "a" | "b"

// Optionals (field may be missing)
v.optional(v.string()); // string | undefined

// Literals (exact values)
v.literal("active"); // "active" literal type
v.literal(42); // 42 literal type
v.literal(true); // true literal type

// Any (escape hatch - avoid if possible)
v.any(); // any (use sparingly!)

Common Validator Patterns

typescript
// Nullable field (can be null)
status: v.union(v.string(), v.null());

// Optional field (may not exist)
nickname: v.optional(v.string());

// Optional AND nullable
deletedAt: v.optional(v.union(v.number(), v.null()));

// Enum-like unions
role: v.union(v.literal("admin"), v.literal("moderator"), v.literal("user"));

// Nested objects
settings: v.object({
  theme: v.union(v.literal("light"), v.literal("dark")),
  notifications: v.object({
    email: v.boolean(),
    push: v.boolean(),
  }),
});

// Array of objects
members: v.array(
  v.object({
    userId: v.id("users"),
    role: v.string(),
    joinedAt: v.number(),
  })
);

Function Validators

CRITICAL: Every Function MUST Have returns Validator

typescript
// ❌ WRONG: Missing returns
export const foo = mutation({
  args: {},
  handler: async (ctx) => {
    // implicitly returns undefined
  },
});

// ✅ CORRECT: Explicit v.null()
export const foo = mutation({
  args: {},
  returns: v.null(),
  handler: async (ctx) => {
    return null;
  },
});

Query with Validators

typescript
import { query } from "./_generated/server";
import { v } from "convex/values";

export const getUser = query({
  args: {
    userId: v.id("users"),
  },
  returns: v.union(
    v.object({
      _id: v.id("users"),
      _creationTime: v.number(),
      name: v.string(),
      email: v.string(),
      role: v.string(),
    }),
    v.null()
  ),
  handler: async (ctx, args) => {
    return await ctx.db.get(args.userId);
  },
});

Mutation with Validators

typescript
import { mutation } from "./_generated/server";
import { v } from "convex/values";

export const createUser = mutation({
  args: {
    name: v.string(),
    email: v.string(),
    role: v.optional(v.union(v.literal("admin"), v.literal("user"))),
  },
  returns: v.id("users"),
  handler: async (ctx, args) => {
    return await ctx.db.insert("users", {
      name: args.name,
      email: args.email,
      role: args.role ?? "user",
      createdAt: Date.now(),
    });
  },
});

Action with Validators

typescript
import { action } from "./_generated/server";
import { v } from "convex/values";

export const processImage = action({
  args: {
    imageUrl: v.string(),
    options: v.object({
      width: v.number(),
      height: v.number(),
      format: v.union(v.literal("png"), v.literal("jpeg")),
    }),
  },
  returns: v.object({
    processedUrl: v.string(),
    size: v.number(),
  }),
  handler: async (ctx, args) => {
    // Process image...
    return {
      processedUrl: "https://...",
      size: 1024,
    };
  },
});

TypeScript Types

Importing Types

typescript
import { Doc, Id } from "./_generated/dataModel";

// Document type for a table
type User = Doc<"users">;
// {
//   _id: Id<"users">;
//   _creationTime: number;
//   name: string;
//   email: string;
//   ...
// }

// ID type for a table
type UserId = Id<"users">;

Using Types in Code

typescript
import { Doc, Id } from "./_generated/dataModel";

// Function parameter types
async function getUserName(
  ctx: QueryCtx,
  userId: Id<"users">
): Promise<string | null> {
  const user = await ctx.db.get(userId);
  return user?.name ?? null;
}

// Variable types
const users: Doc<"users">[] = await ctx.db.query("users").collect();

// Record with Id keys
const userMap: Record<Id<"users">, string> = {};
for (const user of users) {
  userMap[user._id] = user.name;
}

Context Types

typescript
import { QueryCtx, MutationCtx, ActionCtx } from "./_generated/server";

// Query context - read-only
async function readUser(ctx: QueryCtx, id: Id<"users">) {
  return await ctx.db.get(id);
}

// Mutation context - read and write
async function createUser(ctx: MutationCtx, name: string) {
  return await ctx.db.insert("users", { name, createdAt: Date.now() });
}

// Action context - no db, uses runQuery/runMutation
async function processUser(ctx: ActionCtx, id: Id<"users">) {
  const user = await ctx.runQuery(internal.users.getById, { id });
  // ...
}

Index Design

Index Naming Convention

Include all fields in the index name: by_field1_and_field2_and_field3

typescript
// Schema
export default defineSchema({
  messages: defineTable({
    channelId: v.id("channels"),
    authorId: v.id("users"),
    content: v.string(),
    isDeleted: v.boolean(),
  })
    // ✅ This single index serves THREE query patterns:
    // 1. All messages in channel: .eq("channelId", id)
    // 2. Messages by author in channel: .eq("channelId", id).eq("authorId", id)
    // 3. Non-deleted messages by author: .eq("channelId", id).eq("authorId", id).eq("isDeleted", false)
    .index("by_channel_author_deleted", ["channelId", "authorId", "isDeleted"]),
});

// ❌ REDUNDANT: Don't create by_channel if you have by_channel_author_deleted
// The compound index can serve channel-only queries by partial prefix match

Index Usage

typescript
// Using indexes in queries
const messages = await ctx.db
  .query("messages")
  .withIndex("by_channel_author_deleted", (q) =>
    q.eq("channelId", channelId).eq("authorId", authorId).eq("isDeleted", false)
  )
  .collect();

// Partial prefix match (uses first field only)
const allChannelMessages = await ctx.db
  .query("messages")
  .withIndex("by_channel_author_deleted", (q) => q.eq("channelId", channelId))
  .collect();

Validator Extraction from Schema

Reusing Schema Validators

typescript
// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

// Define shared validators
export const userValidator = v.object({
  name: v.string(),
  email: v.string(),
  role: v.union(v.literal("admin"), v.literal("user")),
});

export default defineSchema({
  users: defineTable(userValidator),
});
typescript
// convex/users.ts
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import schema from "./schema";

// Extract validator from schema and extend with system fields
const userDoc = schema.tables.users.validator.extend({
  _id: v.id("users"),
  _creationTime: v.number(),
});

export const getUser = query({
  args: { userId: v.id("users") },
  returns: v.union(userDoc, v.null()),
  handler: async (ctx, args) => {
    return await ctx.db.get(args.userId);
  },
});

Common Patterns

Pattern 1: Status Enum

typescript
// Schema
const statusValidator = v.union(
  v.literal("pending"),
  v.literal("processing"),
  v.literal("completed"),
  v.literal("failed")
);

export default defineSchema({
  jobs: defineTable({
    status: statusValidator,
    data: v.string(),
  }).index("by_status", ["status"]),
});

// Usage in functions
export const getJobsByStatus = query({
  args: {
    status: v.union(
      v.literal("pending"),
      v.literal("processing"),
      v.literal("completed"),
      v.literal("failed")
    ),
  },
  returns: v.array(
    v.object({
      _id: v.id("jobs"),
      _creationTime: v.number(),
      status: v.string(),
      data: v.string(),
    })
  ),
  handler: async (ctx, args) => {
    return await ctx.db
      .query("jobs")
      .withIndex("by_status", (q) => q.eq("status", args.status))
      .collect();
  },
});

Pattern 2: Polymorphic Documents

typescript
// Schema with discriminated union pattern
export default defineSchema({
  notifications: defineTable({
    userId: v.id("users"),
    type: v.union(
      v.literal("message"),
      v.literal("mention"),
      v.literal("system")
    ),
    // Common fields
    read: v.boolean(),
    createdAt: v.number(),
    // Type-specific data stored as object
    data: v.union(
      v.object({ type: v.literal("message"), messageId: v.id("messages") }),
      v.object({
        type: v.literal("mention"),
        messageId: v.id("messages"),
        mentionedBy: v.id("users"),
      }),
      v.object({
        type: v.literal("system"),
        title: v.string(),
        body: v.string(),
      })
    ),
  }).index("by_user", ["userId"]),
});

Pattern 3: Timestamps Pattern

typescript
// Helper for timestamp fields
const timestampsValidator = {
  createdAt: v.number(),
  updatedAt: v.number(),
};

export default defineSchema({
  posts: defineTable({
    title: v.string(),
    body: v.string(),
    authorId: v.id("users"),
    ...timestampsValidator,
  }),
});

Pattern 4: Soft Deletes

typescript
export default defineSchema({
  items: defineTable({
    content: v.string(),
    deletedAt: v.optional(v.number()),
  }).index("by_active", ["deletedAt"]),
});

// Query active items only
const activeItems = await ctx.db
  .query("items")
  .withIndex("by_active", (q) => q.eq("deletedAt", undefined))
  .collect();

Common Pitfalls

Pitfall 1: Using v.bigint() (Deprecated)

❌ WRONG:

typescript
export default defineSchema({
  counters: defineTable({
    value: v.bigint(), // ❌ Deprecated!
  }),
});

✅ CORRECT:

typescript
export default defineSchema({
  counters: defineTable({
    value: v.int64(), // ✅ Use v.int64()
  }),
});

Pitfall 2: Missing System Fields in Return Validators

❌ WRONG:

typescript
export const getUser = query({
  args: { userId: v.id("users") },
  returns: v.object({
    // ❌ Missing _id and _creationTime!
    name: v.string(),
    email: v.string(),
  }),
  handler: async (ctx, args) => {
    return await ctx.db.get(args.userId);
  },
});

✅ CORRECT:

typescript
export const getUser = query({
  args: { userId: v.id("users") },
  returns: v.union(
    v.object({
      _id: v.id("users"), // ✅ Include system fields
      _creationTime: v.number(),
      name: v.string(),
      email: v.string(),
    }),
    v.null()
  ),
  handler: async (ctx, args) => {
    return await ctx.db.get(args.userId);
  },
});

Pitfall 3: Using string Instead of v.id()

❌ WRONG:

typescript
export const getMessage = query({
  args: { messageId: v.string() }, // ❌ Should be v.id()
  returns: v.null(),
  handler: async (ctx, args) => {
    // Type error: can't use string as Id
    return await ctx.db.get(args.messageId);
  },
});

✅ CORRECT:

typescript
export const getMessage = query({
  args: { messageId: v.id("messages") }, // ✅ Proper ID type
  returns: v.union(
    v.object({
      _id: v.id("messages"),
      _creationTime: v.number(),
      content: v.string(),
    }),
    v.null()
  ),
  handler: async (ctx, args) => {
    return await ctx.db.get(args.messageId);
  },
});

Pitfall 4: Redundant Indexes

❌ WRONG:

typescript
export default defineSchema({
  messages: defineTable({
    channelId: v.id("channels"),
    authorId: v.id("users"),
  })
    .index("by_channel", ["channelId"]) // ❌ Redundant!
    .index("by_channel_author", ["channelId", "authorId"]),
});

✅ CORRECT:

typescript
export default defineSchema({
  messages: defineTable({
    channelId: v.id("channels"),
    authorId: v.id("users"),
  })
    // ✅ Single compound index serves both queries
    .index("by_channel_author", ["channelId", "authorId"]),
});
// Use .eq("channelId", id) for channel-only queries (prefix match)
// Use .eq("channelId", id).eq("authorId", authorId) for both

Quick Reference

Validator Cheat Sheet

Type Validator TypeScript
String v.string() string
Number v.number() number
Boolean v.boolean() boolean
Null v.null() null
64-bit Int v.int64() bigint
Bytes v.bytes() ArrayBuffer
Document ID v.id("table") Id<"table">
Array v.array(v.string()) string[]
Object v.object({ x: v.number() }) { x: number }
Record v.record(v.string(), v.number()) Record<string, number>
Union v.union(v.string(), v.null()) string | null
Optional v.optional(v.string()) string | undefined
Literal v.literal("active") "active"

Type Import Cheat Sheet

typescript
// Document and ID types
import { Doc, Id } from "./_generated/dataModel";

// Context types
import { QueryCtx, MutationCtx, ActionCtx } from "./_generated/server";

// Function builders
import { query, mutation, action } from "./_generated/server";
import {
  internalQuery,
  internalMutation,
  internalAction,
} from "./_generated/server";

// Validators
import { v } from "convex/values";

// Schema builders
import { defineSchema, defineTable } from "convex/server";

Expand your agent's capabilities with these related and highly-rated skills.

fluid-tools/claude-skills

vercel-ai-sdk

Guide for Vercel AI SDK v6 implementation patterns including generateText, streamText, ToolLoopAgent, structured output with Output helpers, useChat hook, tool calling, embeddings, middleware, and MCP integration. Use when implementing AI chat interfaces, streaming responses, agentic applications, tool/function calling, text embeddings, workflow patterns, or working with convertToModelMessages and toUIMessageStreamResponse. Activates for AI SDK integration, useChat hook usage, message streaming, agent development, or tool calling tasks.

16 0
Explore
fluid-tools/claude-skills

convex-anti-patterns

Critical rules and common mistakes to avoid in Convex development. Use when reviewing Convex code, debugging issues, or learning what NOT to do. Activates for code review, debugging OCC errors, fixing type errors, or understanding why code fails in Convex.

16 0
Explore
fluid-tools/claude-skills

convex-helpers-patterns

Guide for convex-helpers library patterns including Triggers, Row-Level Security (RLS), Relationship helpers, Custom Functions, Rate Limiting, and Workpool. Use when implementing automatic side effects, access control, relationship traversal, auth wrappers, or concurrency management. Activates for triggers setup, RLS implementation, custom function wrappers, or convex-helpers integration tasks.

16 0
Explore
fluid-tools/claude-skills

convex-schema-validators

Guide for Convex schema design, validators, and TypeScript types. Use when defining database schemas, creating validators for function arguments/returns, working with document types, or ensuring type safety. Activates for schema.ts creation, validator usage, Id/Doc type handling, or TypeScript integration tasks.

16 0
Explore
fluid-tools/claude-skills

typescript-strict-mode

Guide for strict TypeScript practices including avoiding any, using proper type annotations, and leveraging TypeScript's type system effectively. Use when working with TypeScript codebases that enforce strict type checking, when you need guidance on type safety patterns, or when encountering type errors. Activates for TypeScript type errors, strict mode violations, or general TypeScript best practices.

16 0
Explore
fluid-tools/claude-skills

convex-actions-scheduling

Guide for Convex actions, scheduling, cron jobs, and orchestration patterns. Use when implementing external API calls, background jobs, scheduled tasks, cron jobs, or multi-step workflows. Activates for action implementation, ctx.scheduler usage, crons.ts creation, or long-running workflow tasks.

16 0
Explore

Didn't find tool you were looking for?

Be as detailed as possible for better results