Skip to content

Validations ​

Validations check a record before it’s saved. An invalid record isn’t saved, and its errors say why.

Declaring validations ​

Declare validations in a Model’s static {} block with this.validates:

ts
import { Model } from "@forestfuture/d1-record";

export class Account extends Model({
  id: { type: "integer", null: false },
  email: { type: "string", null: false },
  handle: { type: "string", null: false },
  plan: "string",
  seats: "integer",
  website: "string",
  isCompany: "boolean",
  companyName: "string",
}) {
  static {
    this.validates("email", { presence: true, uniqueness: { caseSensitive: false } });
    this.validates("handle", { length: { minimum: 3, maximum: 30 }, format: /^[a-z0-9_]+$/ });
    this.validates("plan", { inclusion: ["free", "team", "enterprise"] });
    this.validates("seats", { numericality: { onlyInteger: true, greaterThan: 0 } });
    this.validates("website", { format: /^https:\/\//, allowNull: true });
    this.validates("companyName", {
      presence: true,
      if: (account) => account.isCompany === true,
      message: "is required for a company",
    });
  }
}

Attribute names are typed, so a misspelled attribute is a compile error.

save() validates first, and resolves to false when a record is invalid. To validate without saving, call isValid():

ts
const account = Account.build(permitted);
const valid = await account.isValid();

TIP

isValid() is async, since uniqueness asks D1.

Available validators ​

RuleOptionsError type
presencetrueblank
length{ minimum, maximum, is }tooShort, tooLong, wrongLength
format{ with: /re/ }, or a regexinvalid
inclusion{ in: [...] }, or an arrayinclusion
numericality{ onlyInteger, greaterThan, greaterThanOrEqualTo, lessThan, lessThanOrEqualTo }, or truenotANumber, notAnInteger, greaterThan, …
uniqueness{ scope: [...], caseSensitive }, or truetaken

Every validation runs, in the order declared, so one attribute can collect several errors. Every rule also accepts these options:

  • allowNull skips the rule when the value is null. Otherwise null fails presence, format, inclusion, and numericality, counts as length 0, and is never taken.
  • message replaces the default messages from that call.
  • if: (record) => boolean validates only when it returns true.

The default messages are in English: “can’t be blank”, “is too short (minimum is 3 characters)”, “has already been taken”, and so on.

Validator details ​

  • presence fails a string that’s only whitespace. For a boolean, use inclusion: [true, false], since false is a value.
  • numericality only accepts numbers, so a numeric string such as "12" is notANumber.
  • uniqueness leaves the record itself out when updating, and never treats null as taken, as SQLite’s UNIQUE does. caseSensitive: false compares with SQLite’s lower(), which only lowercases ASCII letters.

Uniqueness isn’t a guarantee

Two requests can both check that an email is free, then both insert it. uniqueness gives a friendly error early, but only a UNIQUE index in the table prevents duplicates, so declare both. When the index catches a duplicate, the save throws RecordNotUnique.

Reading errors ​

A record’s errors hold each failed rule:

ts
const account = Account.build({ email: "", handle: "a!" });
await account.isValid(); // false

account.errors.get("email"); // ["can't be blank"]
account.errors.fullMessages(); // ["Email can't be blank", "Handle is too short (minimum is 3 characters)", …]
account.errors.toArray(); // [{ attribute: "email", type: "blank", message: "can't be blank" }, …]
console.log(account.errors.size); // 5

Printing errors, in the console or a log, lists each failure’s attribute, type, and message. fullMessages() puts the attribute’s name in front, so createdAt reads as “Created at”. Errors on the record as a whole go on "base", and read without a prefix.

Adding errors ​

For a rule the validators don’t cover, add errors in a beforeValidation or afterValidation callback. A record with any errors is invalid:

ts
import { Model } from "@forestfuture/d1-record";

export class Booking extends Model({
  id: { type: "integer", null: false },
  startsAt: { type: "datetime", null: false },
  endsAt: { type: "datetime", null: false },
}) {
  static {
    this.afterValidation((booking) => {
      if (booking.endsAt <= booking.startsAt) {
        booking.errors.add("endsAt", "must be after the start", { type: "afterStart" });
      }
      if (booking.startsAt.getUTCDay() === 0) {
        booking.errors.add("base", "The room is closed on Sundays");
      }
    });
  }
}

errors.add takes the attribute, or "base", then the message that follows the attribute’s name. Pass a type when code needs to tell the error apart from others. It defaults to "invalid":

ts
const sunday = new Date("2026-05-03T10:00:00Z");
const booking = Booking.build({ startsAt: sunday, endsAt: sunday });
await booking.isValid(); // false

booking.errors.toArray();
// [{ attribute: "endsAt", type: "afterStart", message: "must be after the start" },
//  { attribute: "base", type: "invalid", message: "The room is closed on Sundays" }]
booking.errors.fullMessages(); // ["Ends at must be after the start", "The room is closed on Sundays"]

Each validation run, from isValid(), save(), or saveOrThrow(), starts by clearing errors. So an error added outside a validation callback is gone by the next save.

Compared with Rails

Rails’ errors.add(:ends_at, :after_start) takes a type and looks up its message with I18n. d1-record has no I18n yet, so the message is always yours to give, and the type is an option. When Rails is given a message instead, that text is the type too; here the type is still "invalid". Rails’ message: and strict: options and its procs aren’t here.