Schema Validation
Valkeyrie supports runtime schema validation using Standard Schema, enabling compatibility with popular validation libraries like Zod, Valibot, and ArkType. This feature provides type-safe data validation at write-time and automatic TypeScript type inference for read operations.
Table of Contents
- Why Schema Validation?
- Quick Start
- Type Inference
- Supported Libraries
- Pattern Matching
- Multiple Schemas
- Validation Timing
- Error Handling
- Schema Transformations
- Factory Methods with Schemas
- Best Practices
- Advanced Usage
Why Schema Validation?
Schema validation helps you:
- Catch errors early - Validate data before it's persisted
- Maintain data consistency - Ensure all data matches expected structure
- Get type safety - Automatic TypeScript type inference from schemas
- Document your data - Schemas serve as documentation
- Prevent bugs - Invalid data is rejected before causing problems
Quick Start
Here's a simple example using Zod:
import { Valkeyrie } from 'valkeyrie';
import { z } from 'zod';
// Define your schema
const userSchema = z.object({
name: z.string(),
email: z.string().email(),
age: z.number().min(0)
});
// Register the schema for a key pattern
const db = await Valkeyrie
.withSchema(['users', '*'], userSchema)
.open();
// ✅ Valid data - accepted
await db.set(['users', 'alice'], {
name: 'Alice',
email: 'alice@example.com',
age: 30
});
// ❌ Invalid data - throws ValidationError
await db.set(['users', 'bob'], {
name: 'Bob',
email: 'not-an-email', // Invalid!
age: -5 // Invalid!
});Type Inference
With schema validation, Valkeyrie automatically infers TypeScript types for your operations:
import { z } from 'zod';
const userSchema = z.object({
name: z.string(),
email: z.string().email(),
age: z.number()
});
const db = await Valkeyrie
.withSchema(['users', '*'], userSchema)
.open();
// TypeScript knows the type!
const user = await db.get(['users', 'alice']);
// user.value is typed as: { name: string; email: string; age: number } | null
// This type-checks
if (user.value) {
console.log(user.value.name); // ✅ TypeScript knows 'name' exists
}
// This doesn't type-check
// console.log(user.value.invalid); // ❌ TypeScript errorType Inference for Multiple Operations
Type inference works across all operations:
const postSchema = z.object({
title: z.string(),
content: z.string(),
published: z.boolean()
});
const db = await Valkeyrie
.withSchema(['users', '*'], userSchema)
.withSchema(['posts', '*'], postSchema)
.open();
// get() - automatically typed
const user = await db.get(['users', 'alice']);
// user.value: { name: string; email: string; age: number } | null
const post = await db.get(['posts', 'post-1']);
// post.value: { title: string; content: string; published: boolean } | null
// getMany() - array of typed entries
const entries = await db.getMany([
['users', 'alice'],
['users', 'bob']
]);
// entries: Array<Entry<{ name: string; email: string; age: number }> | { value: null; ... }>
// list() - typed iterator
for await (const entry of db.list({ prefix: ['users'] })) {
// entry.value: { name: string; email: string; age: number }
console.log(entry.value.name); // ✅ Type-safe
}
// watch() - typed stream
const stream = db.watch([
['users', 'alice'],
['posts', 'post-1']
]);
// stream: ReadableStream<[EntryMaybe<User>, EntryMaybe<Post>]>Automatic Type Inference
Type inference works automatically without any type annotations:
// Type is automatically inferred!
const user = await db.get(['users', 'alice']);
// user.value: { name: string; email: string; age: number } | null
const post = await db.get(['posts', 'post-1']);
// post.value: { title: string; content: string; published: boolean } | null
// No `as const` needed - it just works!Supported Libraries
Valkeyrie works with any library that implements the Standard Schema specification:
Zod
import { z } from 'zod';
const productSchema = z.object({
id: z.string(),
name: z.string(),
price: z.number().positive(),
inStock: z.boolean(),
tags: z.array(z.string()).optional()
});
const db = await Valkeyrie
.withSchema(['products', '*'], productSchema)
.open();Valibot
import * as v from 'valibot';
const productSchema = v.object({
id: v.string(),
name: v.string(),
price: v.pipe(v.number(), v.minValue(0)),
inStock: v.boolean(),
tags: v.optional(v.array(v.string()))
});
const db = await Valkeyrie
.withSchema(['products', '*'], productSchema)
.open();ArkType
import { type } from 'arktype';
const productSchema = type({
id: 'string',
name: 'string',
price: 'number>0',
inStock: 'boolean',
'tags?': 'string[]'
});
const db = await Valkeyrie
.withSchema(['products', '*'], productSchema)
.open();All three produce the same validation behavior.
Pattern Matching
Valkeyrie uses patterns to match keys to schemas. The * character acts as a wildcard that matches exactly one key part.
Basic Patterns
// Pattern: ['users', '*']
// Matches: ['users', 'alice'], ['users', 'bob'], ['users', 123]
// Does NOT match: ['users'] (missing part), ['users', 'alice', 'extra'] (too many parts)
const db = await Valkeyrie
.withSchema(['users', '*'], userSchema)
.open();
await db.set(['users', 'alice'], user); // ✅ Matches
await db.set(['users', 123], user); // ✅ Matches (numbers work too)
await db.set(['users'], user); // ❌ Doesn't match (no validation)
await db.set(['users', 'alice', 'x'], user); // ❌ Doesn't match (no validation)Multi-Level Patterns
// Pattern: ['users', '*', 'posts', '*']
// Matches: ['users', 'alice', 'posts', 'post-1']
// Does NOT match: ['users', 'alice', 'posts'] (missing last part)
const postSchema = z.object({
title: z.string(),
content: z.string()
});
const db = await Valkeyrie
.withSchema(['users', '*', 'posts', '*'], postSchema)
.open();
// ✅ Matches pattern - validated
await db.set(['users', 'alice', 'posts', 'post-1'], {
title: 'My First Post',
content: '...'
});
// ❌ Doesn't match pattern - not validated (but still works)
await db.set(['users', 'alice', 'posts'], anyValue);Pattern Priority
Exact patterns take priority over wildcard patterns:
const userSchema = z.object({
name: z.string(),
email: z.string()
});
const adminSchema = z.object({
name: z.string(),
email: z.string(),
permissions: z.array(z.string())
});
const db = await Valkeyrie
.withSchema(['users', '*'], userSchema) // Wildcard pattern
.withSchema(['users', 'admin'], adminSchema) // Exact pattern
.open();
// Uses adminSchema (exact match has priority)
await db.set(['users', 'admin'], {
name: 'Admin',
email: 'admin@example.com',
permissions: ['read', 'write', 'delete']
});
// Uses userSchema (wildcard match)
await db.set(['users', 'alice'], {
name: 'Alice',
email: 'alice@example.com'
// permissions not required
});Reserved Characters
The * character is reserved for patterns and cannot be used as an actual key part:
const db = await Valkeyrie
.withSchema(['users', '*'], userSchema)
.open();
// ❌ Throws TypeError
await db.set(['users', '*'], user);Multiple Schemas
You can register multiple schemas for different key patterns:
import { z } from 'zod';
const userSchema = z.object({
name: z.string(),
email: z.string().email()
});
const postSchema = z.object({
title: z.string(),
content: z.string(),
authorId: z.string()
});
const commentSchema = z.object({
text: z.string(),
authorId: z.string(),
postId: z.string()
});
const db = await Valkeyrie
.withSchema(['users', '*'], userSchema)
.withSchema(['posts', '*'], postSchema)
.withSchema(['comments', '*'], commentSchema)
.open();
// Each key pattern uses its own schema
await db.set(['users', 'alice'], { name: 'Alice', email: 'alice@example.com' });
await db.set(['posts', 'post-1'], { title: 'Hello', content: '...', authorId: 'alice' });
await db.set(['comments', 'c1'], { text: 'Great!', authorId: 'bob', postId: 'post-1' });Permissive by Default
Keys without matching schemas are not validated:
const db = await Valkeyrie
.withSchema(['users', '*'], userSchema)
.open();
// Validated (matches pattern)
await db.set(['users', 'alice'], { name: 'Alice', email: 'alice@example.com' });
// NOT validated (no matching pattern)
await db.set(['settings', 'theme'], 'dark');
await db.set(['cache', 'key'], anyValue);This allows you to use validation only where you need it.
Validation Timing
Understanding when validation occurs:
Write Operations
Validation happens synchronously during set():
try {
await db.set(['users', 'alice'], invalidData);
} catch (error) {
// ValidationError thrown immediately
}Atomic Operations
Validation happens asynchronously at commit() time:
const atomic = db.atomic()
.set(['users', 'alice'], validData)
.set(['users', 'bob'], invalidData) // Not validated yet
.set(['users', 'charlie'], validData);
// Validation happens here
try {
await atomic.commit();
} catch (error) {
// ValidationError for bob's data
// Nothing was committed (atomic guarantee)
}Read Operations
No validation occurs during read operations:
// No validation - always returns stored data
const entry = await db.get(['users', 'alice']);This means reads are fast, but you should be careful if you're manually modifying the database outside of Valkeyrie.
Error Handling
Failed validations throw a ValidationError with detailed information:
import { ValidationError } from 'valkeyrie';
try {
await db.set(['users', 'bob'], {
name: 'Bob',
email: 'invalid-email',
age: -5
});
} catch (error) {
if (error instanceof ValidationError) {
console.log('Failed for key:', error.key);
// ['users', 'bob']
console.log('Validation issues:', error.issues);
// [
// { message: 'Invalid email', path: ['email'] },
// { message: 'Number must be greater than or equal to 0', path: ['age'] }
// ]
}
}Atomic Operation Errors
With atomic operations, the error tells you which mutation failed:
try {
await db.atomic()
.set(['users', 'alice'], validData)
.set(['users', 'bob'], invalidData)
.commit();
} catch (error) {
if (error instanceof ValidationError) {
console.log('Failed mutation key:', error.key);
// ['users', 'bob']
}
}Custom Error Handling
You can wrap validation errors with more context:
async function createUser(id: string, data: unknown) {
try {
await db.set(['users', id], data);
} catch (error) {
if (error instanceof ValidationError) {
throw new Error(`Failed to create user ${id}: ${error.message}`);
}
throw error;
}
}Schema Transformations
Schemas can transform data during validation:
import { z } from 'zod';
const userSchema = z.object({
name: z.string().transform(name => name.trim().toUpperCase()),
email: z.string().email().transform(email => email.toLowerCase()),
age: z.number(),
tags: z.array(z.string()).default([])
});
const db = await Valkeyrie
.withSchema(['users', '*'], userSchema)
.open();
await db.set(['users', 'alice'], {
name: ' alice ', // Will be transformed
email: 'Alice@EXAMPLE.COM', // Will be transformed
age: 30
// tags will be added as []
});
const user = await db.get(['users', 'alice']);
console.log(user.value);
// {
// name: 'ALICE',
// email: 'alice@example.com',
// age: 30,
// tags: []
// }Common Transformations
// Trim whitespace
name: z.string().transform(s => s.trim())
// Normalize emails
email: z.string().email().transform(e => e.toLowerCase())
// Add default values
tags: z.array(z.string()).default([])
// Parse dates
createdAt: z.string().transform(s => new Date(s))
// Sanitize input
bio: z.string().transform(s => s.replace(/<[^>]*>/g, ''))Factory Methods with Schemas
Schema validation works seamlessly with from() and fromAsync():
import { z } from 'zod';
const userSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email()
});
const users = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
{ id: 3, name: 'Charlie', email: 'invalid-email' } // Invalid!
];
// All items are validated during import
const db = await Valkeyrie
.withSchema(['users', '*'], userSchema)
.from(users, {
prefix: ['users'],
keyProperty: 'id'
});
// Throws ValidationError for CharlieError Handling with Factory Methods
const db = await Valkeyrie
.withSchema(['users', '*'], userSchema)
.from(users, {
prefix: ['users'],
keyProperty: 'id',
onError: 'continue', // Don't stop on errors
onErrorCallback: (error, item) => {
console.error(`Failed to import user ${item.id}:`, error);
}
});
// Imports valid items, skips invalid onesBest Practices
1. Validate Critical Data
Not everything needs validation. Focus on:
// ✅ Validate user input
const userSchema = z.object({
name: z.string(),
email: z.string().email()
});
// ✅ Validate external data
const apiResponseSchema = z.object({
id: z.string(),
data: z.unknown()
});
// ❌ Probably don't need validation
// Internal cache or temporary data
await db.set(['cache', 'key'], anyValue);2. Design Patterns Carefully
Think about your key structure before adding schemas:
// Good: Clear hierarchy, easy to validate
const db = await Valkeyrie
.withSchema(['users', '*', 'profile'], profileSchema)
.withSchema(['users', '*', 'settings'], settingsSchema)
.open();
// Bad: Flat structure, hard to organize
const db = await Valkeyrie
.withSchema(['profile', '*'], profileSchema)
.withSchema(['settings', '*'], settingsSchema)
.open();3. Use Transformations Wisely
Transformations are powerful but can be confusing:
// Good: Simple, clear transformations
name: z.string().transform(s => s.trim())
// Bad: Complex logic in transform
name: z.string().transform(s => {
if (s.length > 50) {
return s.slice(0, 50);
}
return s.toUpperCase().trim().replace(/[^a-z]/gi, '');
})
// Better: Do this in application code4. Handle ValidationError Appropriately
try {
await db.set(['users', userId], userData);
} catch (error) {
if (error instanceof ValidationError) {
// Log the specific validation issues
console.error('Validation failed:', error.issues);
// Return user-friendly error
return {
error: 'Invalid user data',
details: error.issues
};
}
// Handle other errors differently
throw error;
}5. Document Your Schemas
// Good: Self-documenting schema
const userSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().min(0).max(150),
bio: z.string().max(500).optional(),
tags: z.array(z.string()).max(10).default([])
});
// Even better: Add descriptions
const userSchema = z.object({
name: z.string()
.min(1, 'Name is required')
.max(100, 'Name too long'),
email: z.string()
.email('Invalid email format'),
age: z.number()
.int('Age must be a whole number')
.min(0, 'Age cannot be negative')
.max(150, 'Age seems unrealistic')
});6. Test Your Schemas
// Test valid data
await db.set(['users', 'test'], validUser);
// Test invalid data
try {
await db.set(['users', 'test'], invalidUser);
throw new Error('Should have thrown ValidationError');
} catch (error) {
if (!(error instanceof ValidationError)) {
throw error;
}
}Advanced Usage
Dynamic Schemas
You can create schemas programmatically:
function createUserSchema(requiredFields: string[]) {
const schema: any = {
name: z.string(),
email: z.string().email()
};
if (requiredFields.includes('age')) {
schema.age = z.number();
}
if (requiredFields.includes('bio')) {
schema.bio = z.string();
}
return z.object(schema);
}
const schema = createUserSchema(['age', 'bio']);
const db = await Valkeyrie
.withSchema(['users', '*'], schema)
.open();Conditional Validation
import { z } from 'zod';
const documentSchema = z.object({
type: z.enum(['draft', 'published']),
title: z.string(),
content: z.string(),
publishedAt: z.date().optional()
}).refine(
(data) => {
// If published, must have publishedAt
if (data.type === 'published') {
return data.publishedAt !== undefined;
}
return true;
},
{
message: 'Published documents must have a publishedAt date',
path: ['publishedAt']
}
);Nested Schemas
const addressSchema = z.object({
street: z.string(),
city: z.string(),
country: z.string(),
zipCode: z.string()
});
const userSchema = z.object({
name: z.string(),
email: z.string().email(),
address: addressSchema, // Nested schema
alternateAddresses: z.array(addressSchema).optional()
});Union Types
const eventSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('user_created'),
userId: z.string(),
name: z.string()
}),
z.object({
type: z.literal('user_deleted'),
userId: z.string()
}),
z.object({
type: z.literal('post_created'),
postId: z.string(),
title: z.string()
})
]);
const db = await Valkeyrie
.withSchema(['events', '*'], eventSchema)
.open();Notes on validation
- Write-time only - Validation only occurs on writes, not reads
- No schema migration - Changing schemas doesn't validate existing data
- Pattern-based only - Cannot validate based on data content, only key patterns
- No cross-key validation - Cannot validate relationships between different keys
Summary
- ✅ How to add schema validation with Zod, Valibot, or ArkType
- ✅ Automatic TypeScript type inference from schemas
- ✅ Pattern matching for different key structures
- ✅ When and how validation occurs
- ✅ Error handling and transformations
- ✅ Best practices for schema design
- ✅ Advanced validation patterns
Next, explore:
- Factory Methods - Import validated data in bulk
- Advanced Patterns - Combine validation with atomic operations
