Skip to content

Surqlize for TypeScript

Schema

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.

table() takes the table name and an object of fields.

Define a table
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.

BuilderTypeScript typeNotes
t.string()string
t.number()number
t.bigint()bigintAn integer too large for a JavaScript number.
t.bool()boolean
t.date()DateA SurrealDB datetime.
t.duration()DurationThe SDK's Duration class.
t.uuid()UuidThe SDK's Uuid class.
t.point()GeometryPointA native SurrealDB point. See search, vector, and geometry.
t.null(), t.none()null, undefinedt.none() is SurrealDB's NONE.
t.never()never
t.option(type)The type, or undefinedThe value may be missing (NONE).
t.object({ ... })An object typeFields 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 typeA 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:

A field with fixed values
const ticket = table("ticket", {
    priority: t.union([t.literal("low"), t.literal("normal"), t.literal("high")]),
});

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.

Optional fields
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" });

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.

Default values
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.

Warning

The text passed to expr() is inserted into the query as it is. Only pass trusted, fixed SurrealQL, never user input.

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.

Computed fields
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.

Using a computed field
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.

Define computed fields
await db.defineComputed();
await db.defineComputed("user");
Generated statement for fullName
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 the tables
DEFINE TABLE user;
DEFINE TABLE post;

A few details:

  • The db argument of the expression is typed as Orm<any>, because the tables are registered after they are defined. Annotate the parameter, as in db: Orm<[typeof post]>, to type the tables it queries.

  • The expression is inlined into the DEFINE statement, so it cannot use query parameters.

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.

Link a class to a table
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 static findByEmail(), are not provided. Write them against your db instance, for example with db.select("user").where(...).

  • With runtime validation, safeParse and parse in "row" mode return an instance of the class.

Was this page helpful?