A Surqlize schema is a collection of table() definitions, plus edge() definitions for graph relations. Each one maps the fields of a table to type builders from t, and TypeScript derives every query type from them.
A schema describes the shape of the data, so that queries can be checked and results parsed. It does not create anything in the database. A table without DEFINE statements is schemaless, and you add constraints with DEFINE TABLE and DEFINE FIELD statements, for example in a migration. The one exception is a computed field, which Surqlize can define for you.
Defining a table
table() takes the table name and an object of fields.
import { table, t } from "surqlize";
const user = table("user", {
name: t.string(),
age: t.number(),
isActive: t.bool(),
created: t.date(),
address: t.object({
street: t.string(),
city: t.string(),
}),
tags: t.array(t.string()),
bio: t.option(t.string()),
status: t.union([t.literal("active"), t.literal("inactive"), t.literal("pending")]),
authorId: t.record("author"),
});Every table has an id field of type RecordId<"user">, which you do not declare.
Field types
| Builder | TypeScript type | Notes |
|---|---|---|
t.string() | string | |
t.number() | number | |
t.bigint() | bigint | An integer too large for a JavaScript number. |
t.bool() | boolean | |
t.date() | Date | A SurrealDB datetime. |
t.duration() | Duration | The SDK's Duration class. |
t.uuid() | Uuid | The SDK's Uuid class. |
t.point() | GeometryPoint | A native SurrealDB point. See search, vector, and geometry. |
t.null(), t.none() | null, undefined | t.none() is SurrealDB's NONE. |
t.never() | never | |
t.option(type) | The type, or undefined | The value may be missing (NONE). |
t.object({ ... }) | An object type | Fields can be nested to any depth. |
t.array(type) | type[] | An array of one type. |
t.array([a, b]) | [a, b] | A tuple, with a type for each position. |
t.union([a, b]) | A union of the given types | |
t.literal(value) | The literal type | A string, number, or boolean. |
t.record() | RecordId<string> | A link to a record in any table. |
t.record("table") | RecordId<"table"> | A link to a record in one table. |
t.record(["a", "b"]) | RecordId<"a" | "b"> | A link to a record in one of several tables. |
A union of literals describes a field that takes one of a fixed list of values:
const ticket = table("ticket", {
priority: t.union([t.literal("low"), t.literal("normal"), t.literal("high")]),
});Optional fields
t.option() marks a field that can be missing. Its value type includes undefined, and the field can be left out when you create a record.
import { orm, t, table } from "surqlize";
const profile = table("profile", {
handle: t.string(),
bio: t.option(t.string()),
});
const db = orm(surreal, profile);
await db.create("profile").content({ handle: "ada" });Default values
Call .default() on any field type to give it a default. The field becomes optional when you create a record, while the type you read back stays non-optional.
import { expr, orm, t, table } from "surqlize";
const account = table("account", {
name: t.string(),
isVerified: t.bool().default(false),
createdAt: t.date().default(() => new Date()),
seenAt: t.date().default(expr("time::now()")),
});
const db = orm(surreal, account);
const created = await db.create("account").content({ name: "Ada" });
// The result has type { id, name: string, isVerified: boolean,
// createdAt: Date, seenAt: Date }A default can be one of three things:
A static value, such as
false.A function, which is called each time the query is sent.
A SurrealQL expression wrapped in
expr(), which SurrealDB evaluates when it runs the query.
Defaults are filled in by the query itself, so no DEFINE FIELD ... DEFAULT is needed. They apply to CREATE with .content(), .merge(), .set(), or no data, and to INSERT with an object or with .fields().values(). RELATE applies them in the same way as CREATE. An explicit value always wins. .content(), .merge(), and INSERT also apply the default when the field is undefined, whereas .set() treats a key that is present as supplied.
Defaults do not apply to UPDATE, UPSERT, .replace(), or .patch().
.default() returns a copy of the type, so a field type shared between tables is never changed.
The text passed to expr() is inserted into the query as it is. Only pass trusted, fixed SurrealQL, never user input.
Computed fields
A computed field is read-only: SurrealDB derives its value from an expression each time the record is read. It maps to DEFINE FIELD ... COMPUTED, which needs SurrealDB 3.0 or later. Add one with .computed(name, type, expression).
The expression receives the current record, rendered as $this, and the ORM, so it can run subqueries.
import { type Orm, orm, t, table } from "surqlize";
const post = table("post", { title: t.string(), author: t.record("user") });
const user = table("user", { first: t.string(), last: t.string() })
.computed("fullName", t.string(), (row) => row.first.concat(" ", row.last))
.computed("postCount", t.number(), (row, db: Orm<[typeof post]>) =>
db
.select("post")
.where((p) => p.author.eq(row.id))
.return((p) => p.id)
.wrap()
.len(),
);
const db = orm(surreal, user, post);Computed fields are part of the record type of selects, and can be used in where, orderBy, and return like any other field. They are left out of the input types of create, update, upsert, insert, and relate. Setting one is a type error, and the database ignores the value at runtime.
const popular = await db.select("user").where((u) => u.postCount.gt(0));
// popular[0].fullName is a string
const sorted = await db.select("user").orderBy("fullName");
await db.create("user", "ada").set({ first: "Ada", last: "Lovelace" });
// @ts-expect-error fullName is read-only
await db.create("user", "byron").set({ fullName: "Ada Lovelace" });The field must exist in the database before it can be read. Create it with db.defineComputed(), which defines the computed fields of every registered table, or of the tables you name. It uses DEFINE FIELD OVERWRITE, so it is safe to run more than once.
await db.defineComputed();
await db.defineComputed("user");DEFINE FIELD OVERWRITE fullName ON TABLE user COMPUTED (string::concat($this.first, " ", $this.last))To put the statements in a migration instead, call user.computedStatements(db), which returns them as an array of strings. The user.computedFields property lists the names of the computed fields.
The postCount expression reads the post table, and SurrealDB rejects a read from a table that does not exist. Define both tables before you read or write records:
DEFINE TABLE user;
DEFINE TABLE post;A few details:
The
dbargument of the expression is typed asOrm<any>, because the tables are registered after they are defined. Annotate the parameter, as indb: Orm<[typeof post]>, to type the tables it queries.The expression is inlined into the
DEFINEstatement, so it cannot use query parameters.
Linking a class to a table
Pass a class as the third argument of table() to give records methods and computed properties. Records selected from the table, and records returned by a write to it, are instances of the class. Instances of the class are accepted as record content.
import { orm, table, t } from "surqlize";
class User {
given_name!: string;
family_name!: string;
get fullName() {
return `${this.given_name} ${this.family_name}`;
}
}
const person = table(
"person",
{ given_name: t.string(), family_name: t.string() },
User,
);
const db = orm(surreal, person);
const input = Object.assign(new User(), { given_name: "Alan", family_name: "Turing" });
await db.create("person", "turing").content(input);
const turing = await db.select("person", "turing").only();
turing instanceof User;
//- true
turing.fullName;
//- "Alan Turing"Type inference needs no code generation: the record type is the fields declared in the table plus the instance type of the class.
Records are hydrated without running the class constructor, so declare fields with
!rather than with initialisers.An instance passed as content is sent as its own enumerable properties. Getters and methods are not stored.
Hydration also applies to records resolved with
.fetch(), each with its own class. Queries that use a.return()projection return plain objects.Methods that run queries themselves, such as
this.update()or a staticfindByEmail(), are not provided. Write them against yourdbinstance, for example withdb.select("user").where(...).With runtime validation,
safeParseandparsein"row"mode return an instance of the class.