An Intro to dry-schema in Ruby

Data structure and type validations are so essential in modern Rails apps. You've probably used Active Model validations and strong parameters, but did you know there is a better way? This article introduces the dry-schema gem—a faster alternative to the defaults!

The dry-schema gem is part of a set of gems in the dry-rb family. Its main focus is data-structure and value-type validation. It is a general-purpose data validation library and multiple times faster than ActiveRecord/ActiveModel::Validations and strong parameters. It can be used for, but is not restricted to, validating the following:

  • Form params
  • "GET" params
  • JSON documents
  • YAML documents
  • Application configuration (i.e., stored in ENV)
  • Replacement for strong-parameters

If you’re asking, "Do I need it?", a quick example might convince you. Consider a "User" model with name and age attributes in a Rails app with validation for the name attribute as shown below.

User Migration

Let's add a validation to the user model:

class User < ApplicationRecord
  validates :name, presence: true
end

In our Rails console, we can see the result of certain commands run:

Rails Errors

We observe that the error shown when the name key is missing is the same as when it's present, but its value is nil. This leads to some confusion about what exactly made the params unacceptable. However, with dry-schema, not only can the key presence be verified separately from values, but validations are also separated from the model. Let's dive into dry-schema and how to use it.

Understanding Dry-Schema Macros

Let's create a new folder named dry-schema and, within it, create a file called user_schema.rb. Within this folder, install the gem using the command gem install dry-schema.

Beginning with tackling the validation error we had above, let's start with the macro called filled. Within the user_schema.rb file, we'll create a user schema and add a validation for the name field.

module User
  require 'dry-schema'

  Schema = Dry::Schema.Params do
    required(:name).filled(:string)
  end
end

Above, we have required the dry-shcema gem and created a schema for the user params. We have indicated that we require the name field to be present and be filled with a string. Starting an irb session, we can load the file via the command load './user_schema.rb'.

User Schema Errors

From the above, we can see that there's a clear distinction between the error shown when a key is absent and when a value is absent. With dry-schema, we're not in doubt about what exactly went wrong with the params provided. Within Rails, these validations can be carried out on a model instance before it is saved, as opposed to ActiveRecord validations.

The filled macro should be employed when a value is expected to be filled. This means that the value is non-nil and, in the case of a string, hash, or array value, that the value is not .empty?.

The opposite of the "filled" macro is the "maybe" macro. It is used when a value can be nil. Macros are specific to the values and not the keys, which means that "maybe" does not in any way signify that a key can be missing or that the value type is not enforced.

module User
  require 'dry-schema'

  Schema = Dry::Schema.Params do
    required(:name).maybe(:string)
  end
end

Maybe Schema

As seen above, there are no errors when the value of the name key is empty or nil, because the "maybe" macro is in play. However, we get an error when the name key is not present because the key is "required". There are several other macros, such as hash, each, schema, and array. You can find out more about them here.

Optional Keys

As stated earlier, we can declare a value optional by using the "maybe" macro. However, to make a key optional, the optional method is invoked.

module User
  require 'dry-schema'

  Schema = Dry::Schema.Params do
    required(:name).filled(:string)
    optional(:age).filled(:integer)
  end
end

Here, we're requiring the "name" key to have a string value but making it optional to supply the "age" key. However, if the "age" key is present, we expect that it must be filled with a value of integer type.

Optional key

Notice something cool? One of the features of dry-schema is type coercion. As seen above, despite the age value being supplied as a string type, it is coerced into an integer type. Also, we have no errors when the "age" key is not supplied, however, when supplied, there is an insistence on it being filled with a value of integer type.

Carrying out Type and Value Checks

As seen above, we have already been introduced to two type checks for values: "string" and "integer". However, values can be type-checked directly using the "value" method, and further checks can be carried out on these types, such as size checks, using built-in predicates.

Examples:

module User
  require 'dry-schema'

  Schema = Dry::Schema.Params do
    required(:age).value(:integer, gt?: 18)
  end
end

Value Checks

The first error shows us that the type is wrong, but when corrected, the second check for value is carried out using the built-in predicates. This can also be carried further down into arrays:

module User
  require 'dry-schema'

  Schema = Dry::Schema.Params do
    required(:words).value(array[:string], size?: 3)
  end
end

Array Checks

As seen above, the errors are thrown accordingly, based on the values provided and how well they conform to our requirements. The dry-schema built-in predicates can also be used to check values directly without initial type checks.

For example:

module User
  require 'dry-schema'

  Schema = Dry::Schema.Params do
    required(:age).value(eql?: 12)
  end
end

You’ll find a list of more predicates here and how to use them.

Working with Schemas

To accurately work with schemas, one would need to know how to access the result of schema calls and determine whether the calls were successful. Let's assume that a school was hosting a party, and all students would have to enroll for the party; however, there are conditions to be met before being successfully enrolled. We can draw up a schema of the types and values we would accept, validate the params provide using this schema, and if successful, go ahead and enroll a student.

class PartyEnrollment
  attr_accessor :enrollment_list

  def initialize
    @enrollment_list = []
  end

  def enroll(enrollment_params)
    validation = Student::PartySchema.call(enrollment_params)

    if validation.success?
      enroll_student(validation)
    else
      reject_student(validation)
    end
  end

  private

  def enroll_student(validation)
    student_details = validation.to_h
    enrollment_list.push(student_details[:name])
    "You have been successfully enrolled"
  end

  def reject_student(validation)
    errors = validation.errors.to_h
    errors.each do |key, value|
      puts "Your #{key} #{value.first}"
    end
  end
end

As seen above, we have a PartyEnrollment class, which, when initialized, possesses an enrollment list. When the enroll method is called, we ask the StudentSchema to validate the parameters supplied. To determine whether the validation was successful, we have the .success? method, and to check whether it wasn't, we have the .failure? method. If successful, we proceed to the enroll_student method, where we see that we can access the result of this validation as a hash by calling the to_h method on it; otherwise, we go ahead and reject that student using the reject_student method, where we access the errors by calling the errors method on the validation and then, proceed to convert it to a hash for easy accessibility.

Next in line would be to write the student schema to determine the rules we want to be applied. Let's assume that in our case, we would want the name and age filled, and we would be checking that the student is above 15 years of age.

module Student
  require 'dry-schema'

  PartySchema = Dry::Schema.Params do
    required(:name).filled(:string)
    required(:age).filled(:integer, gt?: 15)
  end
end

Let's see if this works 🤞.

Party Checks

Re-using Schemas

Schemas can be re-used. This ensures that we can keep our code dry. In addition to the example above, let's include an address to the required parameters to enable the school bus to drop students off at home after the supposed party. However, we're certain that this is not the only occasion where we would be required to save a student's address. Let's create an address schema to take care of this need:

AddressSchema = Dry::Schema.Params do
  required(:street).filled(:string)
  required(:city).filled(:string)
  required(:zipcode).filled(:string)
end

We can use the AddressSchema within the party schema this way:

PartySchema = Dry::Schema.Params do
  required(:name).filled(:string)
  required(:age).filled(:integer, gt?: 15)
  required(:address).filled(AddressSchema)
end

Attempting to enroll a student for the party without providing the proper address results in the following errors:

Address Errors

As seen above, the address field is being validated by the AddressSchema; furthermore, since the address is a hash, the error for the address is in that same format {:address=>{:city=>["is missing"],:zipcode=>["is missing"]}}. As a result, the error we're returning to the user will need to be rewritten to take this into consideration.

Conclusion

In the dry-schema documentation, we find the following schema-definition best practices:

  • Be specific about the exact shape of the data, and define all the keys that you expect to be present.
  • Specify optional keys, too, even if you don't need additional rules to be applied to their values.
  • Specify type specs for all values.
  • Assign schema objects to constants for convenient access.
  • Define a base schema for your application with a common configuration.

If adhered to, parameter validation can be carried out seamlessly, thereby reducing the number of errors encountered within an application. You can find more information about dry-schema in the documentation.

What to do next:
  1. Sign up for a FREE Honeybadger account
    Honeybadger helps you find and fix errors before your users can even report them. Get set up in minutes and check monitoring off your to-do list.
    Get started free
  2. Get the Honeybadger newsletter
    Each month we share news, best practices, and stories from the DevOps & monitoring community—exclusively for developers like you.
    author photo

    Abiodun Olowode

    Abiodun is a software engineer who works with Ruby/Rails and React. She is passionate about sharing knowledge via writing/speaking and spends her free time singing, binge-watching movies or watching football games.

    More articles by Abiodun Olowode
    “We've looked at a lot of error management systems. Honeybadger is head and shoulders above the rest and somehow gets better with every new release.” 
    Michael Smith
    Try Honeybadger Free for 15 Days
    Are you using Sentry, Rollbar, Bugsnag, or Airbrake for your monitoring? Honeybadger includes error tracking with a whole suite of amazing monitoring tools — all for probably less than you're paying now. Discover why so many companies are switching to Honeybadger here.
    Try Honeybadger Free for 15 Days
    Stop digging through chat logs to find the bug-fix someone mentioned last month. Honeybadger's built-in issue tracker keeps discussion central to each error, so that if it pops up again you'll be able to pick up right where you left off.
    Try Honeybadger Free for 15 Days
    "Wow — Customers are blown away that I email them so quickly after an error."
    Chris Patton
    Try Honeybadger Free for 15 Days