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:
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():
const account = Account.build(permitted);
const valid = await account.isValid();TIP
isValid() is async, since uniqueness asks D1.
Available validators
| Rule | Options | Error type |
|---|---|---|
presence | true | blank |
length | { minimum, maximum, is } | tooShort, tooLong, wrongLength |
format | { with: /re/ }, or a regex | invalid |
inclusion | { in: [...] }, or an array | inclusion |
numericality | { onlyInteger, greaterThan, greaterThanOrEqualTo, lessThan, lessThanOrEqualTo }, or true | notANumber, notAnInteger, greaterThan, … |
uniqueness | { scope: [...], caseSensitive }, or true | taken |
Every validation runs, in the order declared, so one attribute can collect several errors. Every rule also accepts these options:
allowNullskips the rule when the value isnull. Otherwisenullfailspresence,format,inclusion, andnumericality, counts as length 0, and is never taken.messagereplaces the default messages from that call.if: (record) => booleanvalidates only when it returnstrue.
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
presencefails a string that’s only whitespace. For a boolean, useinclusion: [true, false], sincefalseis a value.numericalityonly accepts numbers, so a numeric string such as"12"isnotANumber.uniquenessleaves the record itself out when updating, and never treatsnullas taken, as SQLite’s UNIQUE does.caseSensitive: falsecompares with SQLite’slower(), 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:
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); // 5Printing 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:
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":
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.