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
📚 API Reference: For complete API documentation including all methods and TypeScript types, see Schema Module API Reference and Model Module API Reference.
Next: Learn about Queries for advanced querying capabilities.