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.

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "convex-schema-validators" with this command: npx skills add fluid-tools/claude-skills/fluid-tools-claude-skills-convex-schema-validators

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:

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

✅ CORRECT:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// 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),
});
// 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

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

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

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

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:

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

✅ CORRECT:

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

Pitfall 2: Missing System Fields in Return Validators

❌ WRONG:

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:

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:

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:

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:

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:

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

TypeValidatorTypeScript
Stringv.string()string
Numberv.number()number
Booleanv.boolean()boolean
Nullv.null()null
64-bit Intv.int64()bigint
Bytesv.bytes()ArrayBuffer
Document IDv.id("table")Id<"table">
Arrayv.array(v.string())string[]
Objectv.object({ x: v.number() }){ x: number }
Recordv.record(v.string(), v.number())Record<string, number>
Unionv.union(v.string(), v.null())string | null
Optionalv.optional(v.string())string | undefined
Literalv.literal("active")"active"

Type Import Cheat Sheet

// 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";

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

vercel-ai-sdk

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

typescript-strict-mode

No summary provided by upstream source.

Repository SourceNeeds Review
General

convex-performance-patterns

No summary provided by upstream source.

Repository SourceNeeds Review
General

convex-anti-patterns

No summary provided by upstream source.

Repository SourceNeeds Review