ruby 133 lines · 4 tabs

Validated JSON Schema with dry-validation-style contract (lightweight)

Shared by codesnips Jan 2026
4 tabs
class Contract
  INVALID = Object.new.freeze

  COERCERS = {
    string: ->(v) { v.is_a?(String) ? v : v.to_s },
    integer: ->(v) { Integer(v.to_s) rescue INVALID },
    float: ->(v) { Float(v.to_s) rescue INVALID },
    boolean: ->(v) { [true, "true", "1"].include?(v) ? true : [false, "false", "0"].include?(v) ? false : INVALID }
  }.freeze

  def self.schema
    @schema ||= {}
  end

  def self.rules
    @rules ||= []
  end

  def self.key(name, type:, required: false)
    schema[name] = { type: type, required: required }
  end

  def self.rule(field, &block)
    rules << [field, block]
  end

  def call(input)
    input ||= {}
    result = Result.new
    values = {}

    self.class.schema.each do |name, opts|
      raw = input[name.to_s] || input[name]
      if raw.nil?
        result.add_error(name, "is required") if opts[:required]
        next
      end
      coerced = COERCERS.fetch(opts[:type]).call(raw)
      if coerced.equal?(INVALID)
        result.add_error(name, "must be a #{opts[:type]}")
      else
        values[name] = coerced
      end
    end

    self.class.rules.each do |field, block|
      next unless values.key?(field)
      message = instance_exec(values, &block)
      result.add_error(field, message) if message.is_a?(String)
    end

    result.values = values
    result
  end
end
4 files · ruby Explain with highlit

This snippet builds a small, self-contained validation contract inspired by dry-validation, avoiding the dependency while keeping the ergonomics that make contracts pleasant to read. The core idea is to separate two concerns that untyped params validation tends to blur together: coercion of raw string input into typed values, and rule checks against those typed values. Splitting them means a rule never has to guess whether it is comparing a String to an Integer.

In Contract base class, Contract.schema collects field declarations via key, each carrying a type symbol and a required flag. The COERCERS table maps those symbols to lambdas; each returns a sentinel INVALID when a value cannot be converted, which lets coercion failures surface as ordinary errors rather than exceptions. call builds a fresh Result, coerces every declared key, records type errors, then runs the block-based rules only against successfully coerced values. This ordering is deliberate — running rules on garbage input produces confusing, cascading messages.

Result#add_error and #success? in Result and errors accumulate errors keyed by field, mirroring the shape dry-validation exposes. Keeping errors as a Hash of arrays means a single field can fail multiple rules and the API can render all of them at once instead of stopping at the first.

CreateUserContract shows the DSL in use. It declares typed keys, marks email and age as required, and adds rule blocks that read cleanly because the values are already coerced — values[:age] >= 18 is a plain integer comparison. Rules receive the whole values hash so cross-field checks like password confirmation stay simple.

The Rack integration tab wires the contract into a real request path. parse_json guards against malformed bodies, and a failing contract short-circuits with a 422 and the structured errors, while success passes typed values downstream. The trade-off versus a full library is no nested schemas or composable predicates, but for flat JSON payloads this stays under a hundred lines, has no runtime dependency, and remains trivial to audit. A developer would reach for this when a service needs disciplined input validation without pulling in a validation framework.


Related snips

Share this code

Here's the card — post it anywhere.

Validated JSON Schema with dry-validation-style contract (lightweight) — share card
Link copied