Models & Schemas
Define schemas using Mongoose-like shorthand and options. Common types: String, Number, Boolean, Date, Array, Object.
What are Schemas and Models?
Schemas define the structure, validation rules, and behavior of your data. They're like blueprints that describe what fields a document can have, what types those fields should be, and what constraints apply.
Models are constructors compiled from schemas. They provide methods for creating, querying, updating, and deleting documents. A model corresponds to a collection in ArangoDB.
Why Use Schemas and Models?
- Type Safety: TypeScript support ensures type correctness at compile time
- Data Validation: Automatic validation ensures data integrity
- Consistency: Enforces consistent data structure across your application
- Developer Experience: Mongoose-like API that's familiar and intuitive
- Performance: Compiled validators and optimized queries
- Hooks: Lifecycle hooks for cross-cutting concerns (logging, hashing, etc.)
Creating a Schema
Schemas can be defined using Mongoose-like shorthand or full object definitions:
Basic Schema
import { Schema } from 'arango-typed';
const UserSchema = new Schema({
name: String,
email: String,
age: Number,
active: Boolean,
createdAt: Date
});
Schema with Options
const UserSchema = new Schema({
name: { type: String, required: true },
email: {
type: String,
required: true,
unique: true,
lowercase: true,
trim: true
},
age: {
type: Number,
min: 0,
max: 150,
default: 0
},
tags: {
type: Array,
of: String,
default: []
},
createdAt: {
type: Date,
default: () => new Date()
}
});
Field Types
arango-typed supports the following field types:
String
{
name: String,
email: { type: String, required: true },
bio: { type: String, maxLength: 500 }
}
Number
{
age: Number,
price: { type: Number, min: 0 },
score: { type: Number, min: 0, max: 100 }
}
Boolean
{
active: Boolean,
verified: { type: Boolean, default: false }
}
Date
{
createdAt: Date,
updatedAt: { type: Date, default: () => new Date() },
publishedAt: Date
}
Array
{
tags: { type: Array, of: String, default: [] },
scores: { type: Array, of: Number },
items: { type: Array, of: Object }
}
Object
{
address: { type: Object },
metadata: { type: Object, default: {} },
settings: {
type: Object,
default: () => ({ theme: 'light' })
}
}
Field Options
Each field can have various options to control its behavior:
required
Makes the field mandatory. Documents without required fields will fail validation.
{
email: { type: String, required: true },
name: { type: String, required: true }
}
default
Sets a default value if the field is not provided. Can be a static value or a function.
{
status: { type: String, default: 'pending' },
createdAt: { type: Date, default: () => new Date() },
count: { type: Number, default: 0 }
}
unique
Ensures the field value is unique across all documents in the collection.
{
email: { type: String, unique: true },
username: { type: String, unique: true, sparse: true }
}
index
Creates an index on the field for faster queries.
{
email: { type: String, index: true },
location: { type: Object, index: 'geo' },
bio: { type: String, index: 'fulltext' }
}
sparse
Creates a sparse index that only includes documents where the field exists.
{
optionalField: { type: String, unique: true, sparse: true }
}
Validation
arango-typed provides built-in and custom validation to ensure data integrity.
Built-in Validators
Built-in validators for common validation needs:
String Validators
{
email: {
type: String,
minLength: 5,
maxLength: 100,
match: /^[^\s@]+@[^\s@]+\.[^\s@]+$/
},
password: {
type: String,
minLength: 8,
maxLength: 128
}
}
Number Validators
{
age: {
type: Number,
min: 0,
max: 150
},
price: {
type: Number,
min: 0
}
}
Enum Validator
{
status: {
type: String,
enum: ['pending', 'approved', 'rejected']
},
role: {
type: String,
enum: ['user', 'admin', 'moderator']
}
}
Custom Validators
Define custom validation functions for complex validation logic:
const UserSchema = new Schema({
email: {
type: String,
validate: {
validator: (v: string) => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
},
message: 'Invalid email format'
}
},
password: {
type: String,
validate: {
validator: (v: string) => {
return v.length >= 8 && /[A-Z]/.test(v) && /[0-9]/.test(v);
},
message: 'Password must be at least 8 characters with uppercase and number'
}
},
age: {
type: Number,
validate: {
validator: (v: number) => v >= 18 && v <= 100,
message: 'Age must be between 18 and 100'
}
}
});
Async Validators
For validation that requires async operations (database checks, API calls):
const UserSchema = new Schema({
username: {
type: String,
validate: {
validator: async (v: string) => {
const existing = await User.findOne({ username: v });
return !existing;
},
message: 'Username already taken'
}
}
});
Validation on Save
Validation runs automatically when saving documents:
try {
const user = await User.create({
email: 'invalid-email', // Will fail validation
age: -5 // Will fail validation
});
} catch (error) {
if (error instanceof ValidationError) {
console.error('Validation errors:', error.errors);
// { email: 'Invalid email format', age: 'Age must be at least 0' }
}
}
Manual Validation
You can also validate data manually:
// Async validation
await userSchema.validate({ email: 'test@example.com', age: 25 });
// Synchronous validation (faster, skips async validators)
userSchema.validateSync({ email: 'test@example.com', age: 25 });
Getters and Setters
Getters and setters allow you to transform data when reading or writing fields.
Getters
Transform data when reading from the database:
const UserSchema = new Schema({
email: {
type: String,
get: (v: string) => v.toLowerCase()
},
firstName: String,
lastName: String,
fullName: {
type: String,
get: function() {
return `${this.firstName} ${this.lastName}`;
}
}
});
Setters
Transform data when writing to the database:
const UserSchema = new Schema({
email: {
type: String,
set: (v: string) => v.toLowerCase().trim()
},
password: {
type: String,
set: (v: string) => hashPassword(v) // Hash password on set
},
slug: {
type: String,
set: (v: string) => v.toLowerCase().replace(/\s+/g, '-')
}
});
Virtual Fields
Virtual fields are computed properties that don't exist in the database but can be accessed like regular fields.
const UserSchema = new Schema({
firstName: String,
lastName: String
});
// Virtual field
UserSchema.virtual('fullName', {
get: function() {
return `${this.firstName} ${this.lastName}`;
}
});
// Usage
const user = await User.create({ firstName: 'John', lastName: 'Doe' });
console.log(user.fullName); // "John Doe"
Hooks (Lifecycle Events)
Hooks allow you to execute functions at specific points in the document lifecycle.
Pre Hooks
Execute before an operation:
pre('save')
Runs before saving a document:
UserSchema.pre('save', async function() {
// Hash password if modified
if (this.isModified('password')) {
this.password = await hashPassword(this.password);
}
// Update timestamp
this.updatedAt = new Date();
});
pre('validate')
Runs before validation:
UserSchema.pre('validate', function() {
// Normalize data before validation
if (this.email) {
this.email = this.email.toLowerCase().trim();
}
});
pre('remove')
Runs before removing a document:
UserSchema.pre('remove', async function() {
// Clean up related data
await Post.deleteMany({ author: this._id });
await Comment.deleteMany({ author: this._id });
});
Post Hooks
Execute after an operation:
post('save')
Runs after saving a document:
UserSchema.post('save', function(doc) {
console.log(`User ${doc.email} saved`);
// Send welcome email, update cache, etc.
});
post('remove')
Runs after removing a document:
UserSchema.post('remove', function(doc) {
console.log(`User ${doc.email} removed`);
// Clean up external resources
});
Indexes
Indexes improve query performance by allowing ArangoDB to quickly find documents.
Single Field Index
const UserSchema = new Schema({
email: { type: String, unique: true, index: true }
});
// Or explicitly
UserSchema.index('email', { unique: true });
Composite Index
Index multiple fields together:
UserSchema.index(['tenantId', 'email'], { unique: true });
UserSchema.index(['status', 'createdAt']);
Index Types
Persistent Index (Default)
UserSchema.index('email', { type: 'persistent', unique: true });
Geo Index
For geospatial queries:
const LocationSchema = new Schema({
location: { type: Object, index: 'geo' }
});
// Or
LocationSchema.index('location', { type: 'geo' });
Fulltext Index
For full-text search:
const PostSchema = new Schema({
content: { type: String, index: 'fulltext' }
});
// Or
PostSchema.index('content', { type: 'fulltext' });
TTL Index
For automatically expiring documents:
const SessionSchema = new Schema({
createdAt: { type: Date, default: () => new Date() }
});
SessionSchema.index('createdAt', {
type: 'ttl',
expireAfterSeconds: 3600 // Expire after 1 hour
});
Creating Models
Models are created from schemas and provide methods for interacting with collections:
Basic Model
import { Schema, model } from 'arango-typed';
const UserSchema = new Schema({
name: String,
email: { type: String, required: true, unique: true }
});
const User = model('users', UserSchema);
Model with TypeScript
Define TypeScript interfaces for type safety:
interface UserDoc {
name: string;
email: string;
age?: number;
active?: boolean;
}
const UserSchema = new Schema({
name: String,
email: { type: String, required: true },
age: Number,
active: { type: Boolean, default: true }
});
const User = model('users', UserSchema);
// Now TypeScript knows the structure!
const user = await User.create({ name: 'John', email: 'john@example.com' });
console.log(user.name); // ✅ Type-safe
Model Options
const User = model('users', UserSchema, {
tenantEnabled: true, // Enable multi-tenancy
tenantField: 'tenantId', // Custom tenant field name
connection: customDb // Custom database connection
});
Model Methods
Models provide various methods for CRUD operations:
Create
// Single document
const user = await User.create({
name: 'John Doe',
email: 'john@example.com',
age: 30
});
// Multiple documents (batch)
const users = await User.create([
{ name: 'Alice', email: 'alice@example.com' },
{ name: 'Bob', email: 'bob@example.com' }
]);
Find
// Find all
const allUsers = await User.find({}).all();
// Find with conditions
const activeUsers = await User.find({ active: true }).all();
// Find one
const user = await User.findOne({ email: 'john@example.com' });
// Find by ID
const userById = await User.findById('users/123');
Update
// Update one
await User.updateOne(
{ email: 'john@example.com' },
{ age: 31 }
);
// Update many
await User.updateMany(
{ active: false },
{ lastLogin: new Date() }
);
// Find and update
const updated = await User.findOneAndUpdate(
{ email: 'john@example.com' },
{ age: 31 }
);
Delete
// Delete one
await User.deleteOne({ email: 'john@example.com' });
// Delete many
await User.deleteMany({ active: false });
// Find and delete
const deleted = await User.findOneAndDelete({ email: 'john@example.com' });
Document Methods
Documents returned from models have additional methods:
save()
Save changes to a document:
const user = await User.findOne({ email: 'john@example.com' });
if (user) {
user.age = 31;
await user.save();
}
remove()
Remove a document:
const user = await User.findOne({ email: 'john@example.com' });
if (user) {
await user.remove();
}
isModified()
Check if a field has been modified:
const user = await User.findOne({ email: 'john@example.com' });
if (user) {
user.age = 31;
console.log(user.isModified('age')); // true
console.log(user.isModified('name')); // false
}
getModifiedPaths()
Get all modified fields:
const user = await User.findOne({ email: 'john@example.com' });
if (user) {
user.age = 31;
user.name = 'John Updated';
console.log(user.getModifiedPaths()); // ['age', 'name']
}
toObject() / toJSON()
Convert document to plain object:
const user = await User.findOne({ email: 'john@example.com' });
const plain = user.toObject();
// or
const json = user.toJSON();
Schema Discriminators (Inheritance)
Discriminators allow you to have multiple models share the same collection but with different schemas. This is useful for inheritance patterns.
import { Schema, Discriminator, model, getDatabase } from 'arango-typed';
// Base schema
const ItemSchema = new Schema({
name: String,
type: String,
price: Number
});
// Create discriminator
const discriminator = new Discriminator(ItemSchema, {
key: 'type',
value: 'item',
database: getDatabase(),
collectionName: 'items'
});
// Product discriminator
const Product = discriminator.discriminator('product',
new Schema({
sku: String,
stock: Number
}),
(schema, db, collection) => model('items', schema)
);
// Service discriminator
const Service = discriminator.discriminator('service',
new Schema({
duration: Number,
hourlyRate: Number
}),
(schema, db, collection) => model('items', schema)
);
// Usage
const product = await Product.create({
name: 'Laptop',
price: 999,
sku: 'LAP-001',
stock: 10
});
const service = await Service.create({
name: 'Consulting',
price: 100,
duration: 2,
hourlyRate: 50
});
Complete Example
Full example of a User model with all features:
import { Schema, model } from 'arango-typed';
import bcrypt from 'bcrypt';
// Define TypeScript interface
interface UserDoc {
email: string;
password: string;
firstName: string;
lastName: string;
age?: number;
active?: boolean;
role?: string;
createdAt?: Date;
updatedAt?: Date;
}
// Create schema
const UserSchema = new Schema({
email: {
type: String,
required: true,
unique: true,
lowercase: true,
trim: true,
validate: {
validator: (v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
message: 'Invalid email format'
}
},
password: {
type: String,
required: true,
minLength: 8,
select: false // Don't return password by default
},
firstName: {
type: String,
required: true,
trim: true
},
lastName: {
type: String,
required: true,
trim: true
},
age: {
type: Number,
min: 0,
max: 150
},
active: {
type: Boolean,
default: true
},
role: {
type: String,
enum: ['user', 'admin', 'moderator'],
default: 'user'
},
createdAt: {
type: Date,
default: () => new Date()
},
updatedAt: {
type: Date,
default: () => new Date()
}
});
// Virtual field
UserSchema.virtual('fullName', {
get: function() {
return `${this.firstName} ${this.lastName}`;
}
});
// Indexes
UserSchema.index('email', { unique: true });
UserSchema.index(['active', 'role']);
// Pre-save hook: Hash password
UserSchema.pre('save', async function() {
if (this.isModified('password')) {
this.password = await bcrypt.hash(this.password, 10);
}
this.updatedAt = new Date();
});
// Post-save hook: Log creation
UserSchema.post('save', function(doc) {
console.log(`User ${doc.email} saved`);
});
// Create model
const User = model('users', UserSchema, {
tenantEnabled: true
});
// Usage
const user = await User.create({
email: 'john@example.com',
password: 'securepassword123',
firstName: 'John',
lastName: 'Doe',
age: 30,
role: 'user'
});
console.log(user.fullName); // "John Doe"
console.log(user.email); // "john@example.com"
Best Practices
- Use TypeScript Interfaces: Define interfaces for type safety and better IDE support
- Validate Early: Use schema validation to catch errors before they reach the database
- Use Defaults: Set sensible defaults for optional fields
- Index Frequently Queried Fields: Create indexes on fields used in WHERE clauses
- Use Hooks Wisely: Keep hooks simple and avoid heavy operations
- Precompute Virtual Fields: For expensive computations, consider storing the result
- Use Setters for Transformation: Normalize data (lowercase, trim) using setters
- Batch Operations: Use
create([])for multiple documents - Lean Queries: Use
findLean()when you don't need document methods - Error Handling: Always handle ValidationError and other errors appropriately