| name | convex-schema-validator |
| displayName | Convex Schema Validator |
| description | Defining and validating database schemas with proper typing, index configuration, optional fields, unions, and migration strategies for schema changes |
| version | 1.0.0 |
| author | Convex |
| tags | ["convex","schema","validation","typescript","indexes","migrations"] |
Convex Schema Validator
Define and validate database schemas in Convex with proper typing, index configuration, optional fields, unions, and strategies for schema migrations.
Documentation Sources
Before implementing, do not assume; fetch the latest documentation:
Instructions
Basic Schema Definition
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()),
createdAt: v.number(),
}),
tasks: defineTable({
title: v.string(),
description: v.optional(v.string()),
completed: v.boolean(),
userId: v.id('users'),
priority: v.union(v.literal('low'), v.literal('medium'), v.literal('high')),
}),
});
Validator Types
| Validator | TypeScript Type | Example |
|---|
v.string() | string | "hello" |
v.number() | number | 42, 3.14 |
v.boolean() | boolean | true, false |
v.null() | null | null |
v.int64() | bigint | 9007199254740993n |
v.bytes() | ArrayBuffer | Binary data |
v.id("table") | Id<"table"> | Document reference |
v.array(v) | T[] | [1, 2, 3] |
v.object({}) | { ... } | { name: "..." } |
v.optional(v) | T | undefined | Optional field |
v.union(...) | T1 | T2 | Multiple types |
v.literal(x) | "x" | Exact value |
v.any() | any | Any value |
v.record(k, v) | Record<K, V> | Dynamic keys |
Index Configuration
export default defineSchema({
messages: defineTable({
channelId: v.id('channels'),
authorId: v.id('users'),
content: v.string(),
sentAt: v.number(),
})
.index('by_channel', ['channelId'])
.index('by_channel_and_author', ['channelId', 'authorId'])
.index('by_channel_and_time', ['channelId', 'sentAt']),
articles: defineTable({
title: v.string(),
body: v.string(),
category: v.string(),
}).searchIndex('search_content', {
searchField: 'body',
filterFields: ['category'],
}),
});
Complex Types
export default defineSchema({
profiles: defineTable({
userId: v.id('users'),
settings: v.object({
theme: v.union(v.literal('light'), v.literal('dark')),
notifications: v.object({
email: v.boolean(),
push: v.boolean(),
}),
}),
}),
orders: defineTable({
customerId: v.id('users'),
items: v.array(
v.object({
productId: v.id('products'),
quantity: v.number(),
price: v.number(),
}),
),
status: v.union(
v.literal('pending'),
v.literal('processing'),
v.literal('shipped'),
v.literal('delivered'),
),
}),
analytics: defineTable({
date: v.string(),
metrics: v.record(v.string(), v.number()),
}),
});
Discriminated Unions
export default defineSchema({
events: defineTable(
v.union(
v.object({
type: v.literal('user_signup'),
userId: v.id('users'),
email: v.string(),
}),
v.object({
type: v.literal('purchase'),
userId: v.id('users'),
orderId: v.id('orders'),
amount: v.number(),
}),
v.object({
type: v.literal('page_view'),
sessionId: v.string(),
path: v.string(),
}),
),
).index('by_type', ['type']),
});
Optional vs Nullable Fields
export default defineSchema({
items: defineTable({
description: v.optional(v.string()),
deletedAt: v.union(v.number(), v.null()),
notes: v.optional(v.union(v.string(), v.null())),
}),
});
Index Naming Convention
Always include all indexed fields in the index name:
export default defineSchema({
posts: defineTable({
authorId: v.id('users'),
categoryId: v.id('categories'),
publishedAt: v.number(),
status: v.string(),
})
.index('by_author', ['authorId'])
.index('by_author_and_category', ['authorId', 'categoryId'])
.index('by_category_and_status', ['categoryId', 'status'])
.index('by_status_and_published', ['status', 'publishedAt']),
});
Schema Migration Strategies
Adding New Fields
users: defineTable({
name: v.string(),
email: v.string(),
});
users: defineTable({
name: v.string(),
email: v.string(),
avatarUrl: v.optional(v.string()),
});
Backfilling Data
import { internalMutation } from './_generated/server';
import { v } from 'convex/values';
export const backfillAvatars = internalMutation({
args: {},
returns: v.number(),
handler: async ctx => {
const users = await ctx.db
.query('users')
.filter(q => q.eq(q.field('avatarUrl'), undefined))
.take(100);
for (const user of users) {
await ctx.db.patch(user._id, {
avatarUrl: `https://api.dicebear.com/7.x/initials/svg?seed=${user.name}`,
});
}
return users.length;
},
});
Making Optional Fields Required
users: defineTable({
name: v.string(),
email: v.string(),
avatarUrl: v.string(),
});
Examples
Complete E-commerce Schema
import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';
export default defineSchema({
users: defineTable({
email: v.string(),
name: v.string(),
role: v.union(v.literal('customer'), v.literal('admin')),
createdAt: v.number(),
})
.index('by_email', ['email'])
.index('by_role', ['role']),
products: defineTable({
name: v.string(),
description: v.string(),
price: v.number(),
category: v.string(),
inventory: v.number(),
isActive: v.boolean(),
})
.index('by_category', ['category'])
.index('by_active_and_category', ['isActive', 'category'])
.searchIndex('search_products', {
searchField: 'name',
filterFields: ['category', 'isActive'],
}),
orders: defineTable({
userId: v.id('users'),
items: v.array(
v.object({
productId: v.id('products'),
quantity: v.number(),
priceAtPurchase: v.number(),
}),
),
total: v.number(),
status: v.union(
v.literal('pending'),
v.literal('paid'),
v.literal('shipped'),
v.literal('delivered'),
v.literal('cancelled'),
),
shippingAddress: v.object({
street: v.string(),
city: v.string(),
state: v.string(),
zip: v.string(),
country: v.string(),
}),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_user', ['userId'])
.index('by_user_and_status', ['userId', 'status'])
.index('by_status', ['status']),
reviews: defineTable({
productId: v.id('products'),
userId: v.id('users'),
rating: v.number(),
comment: v.optional(v.string()),
createdAt: v.number(),
})
.index('by_product', ['productId'])
.index('by_user', ['userId']),
});
Using Schema Types in Functions
import { query, mutation } from './_generated/server';
import { v } from 'convex/values';
import { Doc, Id } from './_generated/dataModel';
type Product = Doc<'products'>;
type ProductId = Id<'products'>;
export const get = query({
args: { productId: v.id('products') },
returns: v.union(
v.object({
_id: v.id('products'),
_creationTime: v.number(),
name: v.string(),
description: v.string(),
price: v.number(),
category: v.string(),
inventory: v.number(),
isActive: v.boolean(),
}),
v.null(),
),
handler: async (ctx, args): Promise<Product | null> => {
return await ctx.db.get(args.productId);
},
});
Best Practices
- Never run
npx convex deploy unless explicitly instructed
- Never run any git commands unless explicitly instructed
- Always define explicit schemas rather than relying on inference
- Use descriptive index names that include all indexed fields
- Start with optional fields when adding new columns
- Use discriminated unions for polymorphic data
- Validate data at the schema level, not just in functions
- Plan index strategy based on query patterns
Common Pitfalls
- Missing indexes for queries - Every withIndex needs a corresponding schema index
- Wrong index field order - Fields must be queried in order defined
- Using v.any() excessively - Lose type safety benefits
- Not making new fields optional - Breaks existing data
- Forgetting system fields - _id and _creationTime are automatic
References