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.

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
    An advertisement for Honeybadger that reads 'Turn your logs into events.'

    "Splunk-like querying without having to sell my kidneys? nice"

    That’s a direct quote from someone who just saw Honeybadger Insights. It’s a bit like Papertrail or DataDog—but with just the good parts and a reasonable price tag.

    Best of all, Insights logging is available on our free tier as part of a comprehensive monitoring suite including error tracking, uptime monitoring, status pages, and more.

    Start logging for FREE
    Simple 5-minute setup — No credit card required