Skip to content

Unreal-ORM Tutorial

We will be building a small blog API with users, posts, and comments using Unreal-ORM and SurrealDB.

This tutorial focuses on Unreal-ORM features and how to use the ORM effectively. We expect it to take around 20-25 minutes if you follow along.


Unreal-ORM is designed for SurrealDB and can run on Node.js or Bun. For this tutorial, we’ll use Node.js with the in-memory SurrealDB database.

Terminal window
npm init -y
npm install surrealdb unreal-orm @surrealdb/node
npm install -D typescript @types/node tsx

Note: @surrealdb/node is required for running SurrealDB embedded locally in a Node.js environment. This is great for prototyping, development, and testing.

Create src/index.ts:

src/index.ts
import { Surreal } from 'surrealdb';
import { surrealdbNodeEngines } from '@surrealdb/node';
import Table, { Field } from 'unreal-orm';
const db = new Surreal({ engines: surrealdbNodeEngines() });
async function main() {
// Connect to in-memory database
await db.connect('mem://');
await db.use({ namespace: 'blog', database: 'tutorial' });
console.log('Connected to SurrealDB!');
}
main().catch(console.error);

Run it to verify setup:

Terminal window
npx tsx src/index.ts

You should see “Connected to SurrealDB!” output.


Let’s create a User model with basic fields:

src/index.ts
import { Surreal } from 'surrealdb';
import Table, { Field, applySchema } from 'unreal-orm';
class User extends Table.normal({
name: 'user',
fields: {
name: Field.string(),
email: Field.string(),
bio: Field.option(Field.string()),
},
schemafull: true,
}) {}
async function main() {
const db = new Surreal();
await db.connect('memory://');
await db.use({ namespace: 'blog', database: 'tutorial' });
// Apply schema to database
await applySchema(db, [User]);
// Create a user
const user = await User.create(db, {
name: 'Alice',
email: 'alice@example.com',
bio: 'Full-stack developer'
});
console.log('Created user:', user);
}

Run this and you should see your first user created with type safety!


Unreal-ORM automatically generates SurrealQL DDL statements for your models:

Note on Required Fields: In Unreal-ORM, fields are required by default. This means SurrealDB will reject any write operation where a required field is missing. To make a field optional, you must wrap it in Field.option().

-- The ORM generates and applies:
DEFINE TABLE user SCHEMAFULL;
DEFINE FIELD name ON TABLE user TYPE string;
DEFINE FIELD email ON TABLE user TYPE string;
DEFINE FIELD bio ON TABLE user TYPE option<string>;

The applySchema function applies all these definitions to SurrealDB.

Sometimes you want to generate DDL without executing it (e.g. for migration scripts or CI schema-drift checks):

import { generateFullSchemaQl } from 'unreal-orm';
// Pass one or more model classes – returns a single SurrealQL script
const ddl = generateFullSchemaQl([User, Post]);
console.log(ddl);
// db.query(ddl) // you can run it manually later

Use this in pipelines to compare the generated DDL against committed files and fail the build if they differ.


Now let’s explore the core features of Unreal-ORM: performing CRUD operations, adding custom business logic to models, and defining database indexes.

Unreal-ORM provides a complete, type-safe API for creating, reading, updating, and deleting records.

// --- 1. Create ---
const user = await User.create(db, {
name: 'Alice',
email: 'alice@example.com',
bio: 'Developer',
});
// --- 2. Read ---
// Find a single record by its ID
const foundUser = await User.select(db, { from: user.id, only: true });
// --- 3. Update & Merge ---
// For partial updates (most common), use .merge()
const mergedUser = await foundUser.merge(db, {
bio: 'Senior Developer',
});
// For full document replacement, use .update()
// Note: You must provide ALL required fields.
const updatedUser = await mergedUser.update(db, {
name: 'Alice Smith',
email: 'alice.smith@example.com',
bio: 'Lead Developer',
});
// --- 4. Delete ---
// You can delete a record using the instance method
await updatedUser.delete(db);
// Or delete by ID using the static method
// await User.delete(db, updatedUser.id);

You can add custom business logic directly to your model classes. Instance methods have access to record data via this, while static methods are useful for creating custom queries.

class User extends Table.normal({
/* ...fields... */
}) {
// Instance Method
getDisplayName() {
return `${this.name} <${this.email}>`;
}
// Static Method
static async findByEmail(db: Surreal, email: string) {
const users = await this.select(db, {
where: 'email = $email',
vars: { email },
});
return users[0]; // Return the first match or undefined
}
}
// --- Using Custom Methods ---
const bob = await User.create(db, { name: 'Bob', email: 'bob@example.com' });
// Call the static finder
const foundBob = await User.findByEmail(db, 'bob@example.com');
// Call the instance method
console.log(foundBob?.getDisplayName()); // Outputs: "Bob <bob@example.com>"

Indexes are crucial for query performance and enforcing data integrity. Define them with Index.define() and pass them to applySchema alongside your models.

// Define a unique index on the email field
const UserEmailIndex = Index.define(() => User, {
name: 'user_email_unique',
fields: ['email'],
unique: true, // Enforce uniqueness
});
// Apply schema for models AND indexes
await applySchema(db, [User, Post, UserEmailIndex]);
// Now, SurrealDB will throw an error if you try to create
// two users with the same email address.

Define relationships between models using Field.record() and fetch related data with the fetch option.

Circular Dependencies? Use a thunk () : any => OtherModel inside Field.record() when two models reference each other.
The : any type annotation suppresses TypeScript’s self-referencing complaint and disappears at runtime.

class Post extends Table.normal({
name: 'post',
fields: {
title: Field.string(),
content: Field.string(),
author: Field.record(() => User),
tags: Field.option(Field.array(Field.string())),
published: Field.bool({ default: false }),
},
schemafull: true,
}) {}
async function testRelations() {
const author = await User.create(db, { name: 'Charlie', email: 'charlie@example.com' });
const post = await Post.create(db, {
title: 'Hydration is Awesome',
content: '...',
author: author.id,
});
// Fetch the post and its author
const result = await Post.select(db, {
from: post.id,
only: true,
fetch: ['author'],
});
// result.author is now a fully-typed User instance!
console.log(`Post by ${result?.author.getDisplayName()}`);
}

Customize fields with default, assert (validation), comment, etc.:

class Post extends Table.normal({
name: 'post',
fields: {
title: Field.string({
assert: '$value.length > 5',
comment: 'Post title (min 6 chars)',
default: "'Untitled'",
}),
content: Field.string(),
},
schemafull: true,
}) {}
const Duration = Field.custom<number>('duration');
class Task extends Table.normal({
name: 'task',
fields: {
title: Field.string(),
est: Duration, // stored as SurrealDB duration type, typed as number in TS
},
schemafull: true,
}) {}
// Projection with type-safety
const tasks = await Task.select(db, {
fields: ['title'], // only 'title' will be in the result type
});

Always use vars to pass dynamic values into your queries.

Whenever you build a custom where clause, always inject parameters instead of string-interpolating values. This avoids SurrealQL injection bugs.

// Unsafe (🚫 NEVER string-interpolate user input!)
await User.select(db, {
where: `email = '${userInput}'`
});
// Safe ✅ — use vars
await User.select(db, {
where: 'email = $email',
vars: { email: userInput }
});

vars works in all Unreal-ORM query helpers. SurrealDB substitutes them server-side, giving you:

  • Protection against injection attacks
  • Clear separation between query text and data

Unreal-ORM provides both TypeScript and SurrealDB-level validation:

async function testValidation() {
try {
// This will fail - missing required field 'name'
await User.create(db, {
email: 'incomplete@example.com'
});
} catch (err) {
console.log('Validation error:', err.message);
}
try {
// This will fail - duplicate email (unique index)
await User.create(db, {
name: 'Another Bob',
email: 'bob@example.com' // Already exists
});
} catch (err) {
console.log('Constraint error:', err.message);
}
}

SurrealDB native errors are passed through directly - no ORM-specific error wrapping.


For many-to-many relationships, use Table.relation:

class Comment extends Table.normal({
name: 'comment',
fields: {
content: Field.string(),
author: Field.record(() => User),
post: Field.record(() => Post),
},
schemafull: true,
}) {}
// Edge table for likes
class Liked extends Table.relation({
name: 'liked',
fields: {
in: Field.record(() => User),
out: Field.record(() => Post),
timestamp: Field.datetime({ default: () => new Date() }),
},
schemafull: true,
}) {}
async function testEdges() {
await applySchema(db, [User, Post, Comment, Liked]);
// Create like relationship
const like = await Liked.create(db, {
in: user.id,
out: post.id,
timestamp: new Date()
});
console.log('User liked post at:', like.timestamp);
}

Here’s the full working blog API:

src/blog-api.ts
import { Surreal } from 'surrealdb';
import Table, { Field, Index, applySchema } from 'unreal-orm';
// Models
class User extends Table.normal({
name: 'user',
fields: {
name: Field.string(),
email: Field.string(),
bio: Field.option(Field.string()),
},
schemafull: true,
}) {
getDisplayName() {
return `${this.name} <${this.email}>`;
}
}
class Post extends Table.normal({
name: 'post',
fields: {
title: Field.string(),
content: Field.string(),
author: Field.record(() => User),
published: Field.bool({ default: false }),
},
schemafull: true,
}) {}
// Define a unique index
const UserEmailIndex = Index.define(() => User, {
name: 'user_email_unique',
fields: ['email'],
unique: true,
});
async function main() {
const db = new Surreal();
await db.connect('memory://');
await db.use({ namespace: 'blog', database: 'tutorial' });
// Apply schema for models and indexes
await applySchema(db, [User, Post, UserEmailIndex]);
// Create and test
const author = await User.create(db, {
name: 'Tutorial Author',
email: 'author@example.com',
bio: 'Learning Unreal-ORM'
});
const post = await Post.create(db, {
title: 'Getting Started with Unreal-ORM',
content: 'This ORM is amazing for SurrealDB!',
author: author.id,
published: true
});
// Query with hydration
const result = await Post.select(db, {
from: post.id,
only: true,
fetch: ['author']
});
console.log(`Post "${result?.title}" by ${result?.author.getDisplayName()}`);
await db.close();
}
main().catch(console.error);

Run this and see your complete blog API in action!


vs SurrealDB JS SDK:

FeatureUnreal-ORMSurrealDB SDK
Type Safety✅ Full TypeScript❌ Manual typing
Schema Generation✅ Automatic DDL❌ Manual SQL
Relations & Hydration✅ Typed hydration🟡 Manual joins
Validation✅ TS + DB level❌ Manual checks
Custom Methods✅ Class methods❌ Separate functions

Best Practices:

  • Use schemafull: true for production applications
  • Define custom methods directly in class bodies (no decorators)
  • Use applySchema() in setup/migration scripts
  • Handle SurrealDB native errors directly
  • Use fetch parameter for efficient relation hydration

  • Advanced Relations: Explore more complex many-to-many patterns
  • Permissions: Add SurrealDB table-level permissions
  • Migrations: Version your schema changes
  • Performance: Learn about indexing and query optimization

Check out the API Reference for complete documentation!

Total tutorial time: ~20 minutes ⏱️