An Intro to dry-schema in Ruby
Tags: ruby
Published: 2023-02-16 08:00:00

*This article was originally written by [Abiodun Olowode](https://www.honeybadger.io/blog/authors/abiodunadukeajibade/) on the [Honeybadger Developer Blog](https://www.honeybadger.io/blog/ruby-dry-schema/).*

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](https://www.honeybadger.io/images/blog/posts/ruby-dry-schema/user_migration.png)

Let's add a validation to the user model:

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

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

![Rails Errors](https://www.honeybadger.io/images/blog/posts/ruby-dry-schema/rails_errors.png)

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.

```ruby
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](https://www.honeybadger.io/images/blog/posts/ruby-dry-schema/user_schema_error.png)

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.

```ruby
module User
  require 'dry-schema'

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

![Maybe Schema](https://www.honeybadger.io/images/blog/posts/ruby-dry-schema/maybe.png)

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](https://dry-rb.org/gems/dry-schema/1.5/basics/macros/).

## 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.

```ruby
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](https://www.honeybadger.io/images/blog/posts/ruby-dry-schema/optional_key.png)

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:

```ruby
module User
  require 'dry-schema'

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

![Value Checks](https://www.honeybadger.io/images/blog/posts/ruby-dry-schema/value_checks.png)

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:

```ruby
module User
  require 'dry-schema'

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

![Array Checks](https://www.honeybadger.io/images/blog/posts/ruby-dry-schema/arrays.png)

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:

```ruby
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](https://dry-rb.org/gems/dry-schema/1.5/basics/built-in-predicates/) 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.

```ruby
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.

```ruby
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](https://www.honeybadger.io/images/blog/posts/ruby-dry-schema/party.png)

## 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:

```ruby
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:

```ruby
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](https://www.honeybadger.io/images/blog/posts/ruby-dry-schema/address.png)

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](https://dry-rb.org/gems/dry-schema/1.5/).