Strict

Strict provides a means to strictly validate instantiation of values, instantiation and attribute assignment of objects, and method calls at runtime.

Installation

Strict requires Ruby 3.3 or newer.

Install the gem and add to the application's Gemfile by executing:

$ bundle add strict

If bundler is not being used to manage dependencies, install the gem by executing:

$ gem install strict

Usage

See Supported API for the 2.0 compatibility boundary.

Strict::Value

class Money
  include Strict::Value

  attributes do
    amount_in_cents Integer
    currency AnyOf("USD", "CAD"), default: "USD"
  end
end

Money.new(amount_in_cents: 100_00)
# => #<Money amount_in_cents=100_00 currency="USD">

Money.new(amount_in_cents: 100_00, currency: "CAD")
# => #<Money amount_in_cents=100_00 currency="CAD">

Money.new(amount_in_cents: 100.00)
# => Strict::InitializationError

Money.new(amount_in_cents: 100_00).with(amount_in_cents: 200_00)
# => #<Money amount_in_cents=200_00 currency="USD">

Money.new(amount_in_cents: 100_00).amount_in_cents = 50_00
# => NoMethodError

Money.new(amount_in_cents: 100_00) == Money.new(amount_in_cents: 100_00)
# => true

Subclasses can add attributes while retaining their inherited attributes:

class Person
  include Strict::Value

  attributes do
    name String
  end
end

class Employee < Person
  attributes do
    employee_id String
  end
end

A subclass can also redeclare an inherited attribute by its exact name. The subclass declaration replaces the inherited validator, coercer, and default without changing the parent or the attribute order.

Strict::Union

class PaymentResult
  include Strict::Union

  discriminator :status

  attributes do
    request_id String
  end

  variant :authorized, tag: "payment.authorized" do
    attributes do
      authorization_id String
      amount_in_cents Integer
    end

    def successful? = true
  end

  variant :declined do
    attributes do
      reason String
    end

    def successful? = false
  end
end

authorized = PaymentResult.authorized(
  request_id: "request_123",
  authorization_id: "auth_123",
  amount_in_cents: 1_000
)
authorized.to_h
# => { status: "payment.authorized", request_id: "request_123", authorization_id: "auth_123", amount_in_cents: 1_000 }

authorized.authorized?
# => true

authorized.declined?
# => false

authorized.successful?
# => true

result = PaymentResult.coercer.call(
  "status" => "declined",
  "request_id" => "request_456",
  "reason" => "insufficient_funds"
)
# => #<PaymentResult::Declined status=:declined request_id="request_456" reason="insufficient_funds">

case result
in PaymentResult::Authorized(authorization_id:)
  authorization_id
in PaymentResult::Declined(reason:)
  reason
end
# => "insufficient_funds"

Strict::Object

class Stateful
  include Strict::Object

  attributes do
    some_state String
    dependency Anything(), default: nil
  end
end

Stateful.new(some_state: "123")
# => #<Stateful some_state="123" dependency=nil>

Stateful.new(some_state: "123").with(some_state: "456")
# => NoMethodError

Stateful.new(some_state: "123").some_state = "456"
# => "456"
# => #<Stateful some_state="456" dependency=nil>

Stateful.new(some_state: "123").some_state = 456
# => Strict::AssignmentError

Stateful.new(some_state: "123") == Stateful.new(some_state: "123")
# => false

Validation errors provide structured violations with paths into nested values:

class Batch
  include Strict::Value

  attributes do
    labels ArrayOf(String)
  end
end

begin
  Batch.new(labels: ["ready", 404], extra: true)
rescue Strict::InitializationError => error
  error.violations.map do |violation|
    [violation.path, violation.code, violation.value, violation.validator]
  end
end
# => [
#      [[:labels, 1], :invalid, 404, String],
#      [[:extra], :unexpected, true, nil]
#    ]

The codes are :invalid, :missing, and :unexpected. Custom validators only need to implement ===; Strict reports a rejected value against that validator at the current path. A custom validator can include Strict::DetailedValidator and implement violations(value) when it needs to report relative nested paths:

class Emails
  include Strict::DetailedValidator

  def violations(value)
    unless Array === value
      return [Strict::Violation.new(path: [], code: :invalid, value: value, validator: Array)]
    end

    value.each_with_index.filter_map do |email, index|
      next if String === email

      Strict::Violation.new(path: [index], code: :invalid, value: email, validator: String)
    end
  end
end

The module provides === from violations, and Strict prefixes each relative path with its enclosing attribute, parameter, or collection path.

When an attribute or parameter validator provides a .coercer, Strict uses it automatically before validation. An explicit coerce: value overrides it, and coerce: false disables it. This lets nested Strict values, unions, and interfaces accept the input handled by their class coercers without repeating coerce: in each declaration. ArrayOf and HashOf propagate coercers from their element, key, and value validators, so compositions such as ArrayOf(ValueClass) also coerce automatically.

Strict::Method

class UpdateEmail
  include Strict::Method

  sig do
    user_id String, coerce: ->(value) { value.to_s }
    email String
    returns AnyOf(true, nil)
  end
  def call(user_id:, email:)
    # contrived logic
    user_id == email
  end
end

UpdateEmail.new.call(user_id: 123, email: "123")
# => true

UpdateEmail.new.call(user_id: "123", email: "123")
# => true

UpdateEmail.new.call(user_id: "123", email: 123)
# => Strict::MethodCallError

UpdateEmail.new.call(user_id: "123", email: "456")
# => Strict::MethodReturnError

returns validates the exact value produced by the method. It never coerces or replaces that value, and a successful call returns the same object to the caller.

Strict::Interface

class Storage
  include Strict::Interface

  expose(:write) do
    key String
    contents String
    returns Boolean()
  end

  expose(:read) do
    key String
    returns AnyOf(String, nil)
  end
end

module Storages
  class Memory
    def initialize
      @storage = {}
    end

    def write(key:, contents:)
      storage[key] = contents
      true
    end

    def read(key:)
      storage[key]
    end

    private

    attr_reader :storage
  end
end

adapter = Storages::Memory.new

Storage.implemented_by?(adapter)
# => true

Storage.verify_implementation!(adapter)
# => nil

storage = Storage.new(adapter)
# => #<Storage implementation=#<Storages::Memory>>

storage.write(key: "some/path/to/file.rb", contents: "Hello")
# => true

storage.write(key: "some/path/to/file.rb", contents: {})
# => Strict::MethodCallError

storage.read(key: "some/path/to/file.rb")
# => "Hello"

storage.read(key: "some/path/to/other.rb")
# => nil

module Storages
  class Wat
    def write(key:)
    end
  end
end

storage = Storage.new(Storages::Wat.new)
# => Strict::ImplementationDoesNotConformError

RSpec extensions

Strict provides supported, opt-in integration with RSpec 3.13. RSpec remains an optional dependency and is not loaded by require "strict". Add RSpec to the test bundle:

group :test do
  gem "rspec", "~> 3.13"
end

Then require the adapter from the spec helper:

require "strict/rspec"

The adapter provides matchers for validators and interfaces:

expect(String).to validate("value")
expect(String).not_to validate(1)

expect(Storages::Memory.new).to conform_to(Storage)
expect(Object.new).not_to conform_to(Storage)

When validation fails, validate uses Strict::Violation records to report root and nested Strict::DetailedValidator failure paths.

strict_double builds an RSpec verifying double. For an interface, it stubs every exposed method to nil unless a different result is provided, so the double conforms without extra setup:

storage = strict_double(Storage, write: true, read: "contents")

expect(storage).to conform_to(Storage)
Storage.new(storage).read(key: "some/path")
# => "contents"

RSpec instance doubles also satisfy Strict class validators for attributes, signed parameters, and return values. Plain doubles remain invalid:

class Item
  include Strict::Value

  attributes do
    sku String
  end
end

class Shipment
  include Strict::Value

  attributes do
    item Item
  end
end

item = instance_double(Item, sku: "item_123")
Shipment.new(item: item)
# => #<Shipment item=#<InstanceDouble(Item)>>

Shipment.new(item: double("item"))
# => Strict::InitializationError

Matcher objects can also stand in for validated fields or elements of built-in collection validators when constructing expected Strict values. RSpec recursively applies the nested matchers in argument expectations:

expect(dispatcher).to have_received(:ship).with(
  shipment: Shipment.new(
    item: have_attributes(sku: "item_123")
  )
)

This composition does not change normal Strict value equality or hashing.

Configuration

Strict exposes some configuration options which can be configured globally via Strict.configure { ... } or overridden within a block via Strict.with_overrides(...) { ... }.

Example

# Globally

Strict.configure do |c|
  c.sample_rate = 0.75 # run validation ~75% of the time
end

Strict.configure do |c|
  c.sample_rate = 0 # disable validation (Strict becomes Lenient)
end

Strict.configure do |c|
  c.sample_rate = 1 # always run validation
end

# Locally within the block (only applies to the current execution context)

Strict.with_overrides(sample_rate: 0) do
  # Use Strict as you normally would

  Strict.with_overrides(sample_rate: 0.5) do
    # Overrides can be nested
  end
end

Overrides are local to the current execution context (fiber). They can be nested and are restored when a block returns or raises. Neither a newly created fiber nor a new thread inherits an active override.

Strict.configuration.random

The instance of a Random::Formatter that Strict uses in tandom with the sample_rate to determine when validation should be checked.

Default: Random.new

Strict.configuration.sample_rate

The rate of samples Strict will consider when validating attributes, parameters, and return values. A rate of 0.25 will validate roughly 25% of the time, a rate of 0 will disable validation entirely, and a rate of 1 will always run validations. The sample_rate is used in tandem with random to determine whether validation should be run.

Default: 1

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the specs. You can also run bin/console for an interactive prompt that will allow you to experiment.

Run bundle exec rake benchmark to measure baseline timing and allocations for value initialization, mutable assignment, verified method calls, interface construction, interface calls, to_h, equality, and hashing. Set ITERATIONS, WARMUP_ITERATIONS, or SAMPLES to change the workload, and set FORMAT=markdown to produce a Markdown table. Pull requests publish this table in a non-blocking job summary and upload it as an artifact. These benchmarks report measurements only and do not enforce thresholds.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/kylekthompson/strict. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.

License

The gem is available as open source under the terms of the MIT License.

Code of Conduct

Everyone interacting in the Strict project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.

Credit

I can't thank Tom Dalling enough for his excellent ValueSemantics gem. Strict is heavily inspired and influenced by Tom's work and has some borrowed concepts and code.