Documentation

Validations

New
Browse documentation

Published 2026-08-13. V2 is a prerelease; the web core is release-gated and other previews are named separately. What beta means.

Read this page as HTML, Markdown, or structured JSON—or open the published Markdown with an AI assistant. Gemini receives the prompt through your clipboard because its signed-out page does not reliably prefill URL text; paste when the new tab opens. External assistants need the public site URL.

1.4.1
Unavailable in this version
1.5
Unavailable in this version

Validations

Supported web path: Amber CLI 2.0.6 includes Grant in every generated web application and pins the reviewed V2 commit. Preserve that pin while following this beta.

Where the examples go

Validation declarations, custom validator methods, conditions, and validation callbacks belong inside the matching Grant model under src/models/. Examples that call validation methods or inspect errors run from the controller, service, form object, or spec that owns the operation. Database constraints belong in the migration system selected by the application. Blocks on this page use those destinations unless a closer comment identifies a different role.

Grant runs model validations before persistence and records failures on the model's error collection.

Basic Validation

Crystal
class User < Grant::Base
  column email : String
  column age : Int32

  validates_email :email
  validates_numericality_of :age, greater_than: 0
end

user = User.new(email: "invalid", age: -5)
user.valid?  # => false
user.errors  # => Array of validation errors
user.save    # => false (won't save invalid records)
user.save!   # => raises Grant::RecordInvalid

Built-in Validators

Presence and Absence

Crystal
class Product < Grant::Base
  column name : String
  column internal_notes : String?

  validates_presence_of :name
  validate_not_blank :name

  validates_absence_of :internal_notes  # Must be nil/blank
end

Numericality

Crystal
class Order < Grant::Base
  column total : Float64
  column quantity : Int32
  column discount : Float64

  validates_numericality_of :total, greater_than: 0
  validates_numericality_of :quantity,
    only_integer: true,
    greater_than: 0
  validates_numericality_of :discount,
    greater_than_or_equal_to: 0,
    less_than_or_equal_to: 100
end

Options:

  • greater_than, greater_than_or_equal_to
  • less_than, less_than_or_equal_to
  • equal_to, other_than
  • odd: true, even: true
  • only_integer: true
  • in: range
  • allow_nil: true, allow_blank: true

Format

Crystal
class User < Grant::Base
  column username : String
  column phone : String

  validates_format_of :username, with: /\A[a-zA-Z0-9_]+\z/
  validates_format_of :phone, with: /\A\d{3}-\d{3}-\d{4}\z/
  validates_format_of :username, without: /\A(admin|root)\z/,
    message: "is reserved"
end

Length/Size

Crystal
class Article < Grant::Base
  column title : String
  column body : String
  column tags : Array(String)

  validates_length_of :title, minimum: 5, maximum: 100
  validates_length_of :body, minimum: 100
  validates_size_of :tags, maximum: 10
  validates_length_of :slug, is: 8  # Exactly 8
end

Email and URL

Crystal
class Contact < Grant::Base
  column email : String
  column website : String?

  validates_email :email
  validates_url :website, allow_blank: true
end

Confirmation

Crystal
class Account < Grant::Base
  column email : String
  column password : String

  validates_confirmation_of :email
  validates_confirmation_of :password
end

# Usage requires confirmation fields
account = Account.new(
  email: "[email protected]",
  password: "secret123"
)
account.email_confirmation = "[email protected]"
account.password_confirmation = "secret123"
account.valid?  # => true

Inclusion and Exclusion

Crystal
class Subscription < Grant::Base
  column plan : String
  column username : String

  validates_inclusion_of :plan,
    in: ["free", "basic", "premium", "enterprise"]

  validates_exclusion_of :username,
    in: ["admin", "root", "system"],
    message: "is reserved"
end

Uniqueness

Crystal
class User < Grant::Base
  column email : String
  column employee_id : String
  column company_id : Int64

  validate_uniqueness :email

  # Scoped uniqueness (unique within scope)
  validate_uniqueness :employee_id, scope: :company_id
end

Custom Validations

Block Syntax

Crystal
class Post < Grant::Base
  column title : String
  column content : String

  validate :title, "can't be blank" do |post|
    !post.title.to_s.blank?
  end

  validate :content, "must be at least 10 characters" do |post|
    post.content.size >= 10
  end
end

Method Reference

Crystal
class Product < Grant::Base
  column price : Float64
  column sale_price : Float64?
  column on_sale : Bool

  validate :valid_sale_price

  private def valid_sale_price
    return true unless on_sale && sale_price

    if sale_price.not_nil! >= price
      errors.add(:sale_price, "must be less than regular price")
    end
  end
end

Model-level Validation

Crystal
class Order < Grant::Base
  validate "total must equal sum of line items" do |order|
    calculated_total = order.line_items.sum(&.total_price)
    (order.total_amount - calculated_total).abs < 0.01
  end
end

Conditional Validations

Using Symbols

Crystal
class Post < Grant::Base
  column title : String
  column content : String
  column published : Bool

  validates_length_of :title, minimum: 10, if: :published?
  validates_presence_of :content, unless: :draft?

  def published?
    published == true
  end

  def draft?
    !published
  end
end

Using Procs

Crystal
class Order < Grant::Base
  column payment_method : String
  column credit_card : String?

  validates_presence_of :credit_card,
    if: ->(order : Order) { order.payment_method == "credit" }
end

Validation Contexts

Crystal
class User < Grant::Base
  column email : String
  column password : String

  # Only on create
  validates_presence_of :password, on: :create

  # Only on update
  validates_confirmation_of :password, on: :update

  # Custom context
  validate :email, "must be corporate email", on: :corporate do |user|
    user.email.ends_with?("@company.com")
  end
end

# Usage with context
user.valid?(:corporate)
user.save(context: :corporate)

Working with Errors

Crystal
user = User.new(email: "invalid", age: 10)
user.valid?  # => false

# Get all errors
user.errors  # => Array(Grant::Error)

# Get errors for specific field
email_errors = user.errors.select { |e| e.field == :email }

# Get error messages
user.errors.map(&.message)
# => ["is not a valid email", "must be at least 18"]

# Full error messages
user.errors.map { |e| "#{e.field} #{e.message}" }
# => ["email is not a valid email", "age must be at least 18"]

# Add custom errors
user.errors.add(:base, "Something went wrong")

Custom Error Messages

Crystal
class User < Grant::Base
  validates_numericality_of :age,
    greater_than_or_equal_to: 18,
    message: "You must be at least 18 years old"

  validates_format_of :email,
    with: /@company\.com\z/,
    message: "must be a company email address"
end

Validation Callbacks

Crystal
class User < Grant::Base
  before_validation :normalize_email
  after_validation :set_defaults

  private def normalize_email
    self.email = email.downcase.strip if email
  end

  private def set_defaults
    self.role ||= "user" if errors.empty?
  end
end

Skipping Validations

Crystal
# Skip validations (use carefully!)
user.save(validate: false)

# Bulk operations skip validations
User.update_all(active: false)

Best Practices

1. Layer Validations

Crystal
class CreditCard < Grant::Base
  # Format validation
  validates_format_of :number, with: /\A\d{16}\z/

  # Business logic validation
  validate :number, "must pass Luhn check" do |card|
    LuhnValidator.valid?(card.number)
  end

  # Database constraint (in migration)
  # ADD CONSTRAINT valid_card_number CHECK (char_length(number) = 16)
end

2. Add Database Constraints

Crystal
# Model validation
validate_uniqueness :email

# Also add database constraint
# CREATE UNIQUE INDEX users_email_unique ON users(email);

3. Order Validations by Cost

Crystal
class Product < Grant::Base
  # Fast validations first
  validates_presence_of :name
  validates_length_of :name, in: 1..100

  # Database queries later
  validate_uniqueness :sku

  # Expensive operations last
  validate :image, "must be valid" do |product|
    ImageValidator.valid?(product.image_data) if product.image_data
  end
end