Back to Scaling Ruby Applications guides

How to Use Dry-rb for Functional Programming in Ruby

Stanley Ulili
Updated on November 4, 2025

Dry-rb is a collection of next-generation Ruby libraries designed to bring functional programming patterns and better code organization to your Ruby applications. The ecosystem provides lightweight, standalone gems that address common pain points in Ruby development, from data validation and type checking to dependency injection and monads.

The dry-rb ecosystem includes specialized gems for different aspects of application development, including data validation, type coercion, struct definitions, and functional patterns. What makes these libraries compelling is their modular nature, allowing you to adopt only the pieces you need without committing to an entire framework. Each gem is designed to work independently while integrating seamlessly with the others when you need more comprehensive solutions.

This guide will walk you through the core dry-rb libraries and show you how to integrate them into your Ruby projects. You'll learn how to leverage these tools to write more robust, maintainable code with better error handling and clearer data flows.

Prerequisites

Before proceeding with the rest of this article, ensure you have a recent version of Ruby installed locally on your machine. This article assumes you're comfortable with Ruby syntax and basic object-oriented programming concepts.

Getting started with dry-rb

To follow along with this tutorial effectively, create a new Ruby project to experiment with the concepts we'll be discussing. Start by initializing a new project using the commands below:

 
mkdir dry-rb-demo && cd dry-rb-demo
 
bundle init

We'll start with three core dry-rb gems that work well together. Add them to your Gemfile:

Gemfile
source 'https://rubygems.org'

gem 'dry-types', '~> 1.8'
gem 'dry-struct', '~> 1.6'
gem 'dry-validation', '~> 1.11'

Install the gems through Bundler:

 
bundle install

Create a new types.rb file in the root of your project directory and populate it with the following contents:

types.rb
require 'dry-types'

module Types
  include Dry.Types()
end

This snippet creates a module that includes all of dry-types' built-in types, which you'll use throughout your application. We'll explore the available types and how to create custom ones as we progress through this guide.

Now create a simple example to see dry-types in action:

user_age.rb
require_relative 'types'

age = Types::Integer[25]
puts "Age: #{age}"

begin
  invalid_age = Types::Integer["twenty-five"]
rescue Dry::Types::CoercionError => e
  puts "Error: #{e.message}"
end

Execute the program using the following command:

 
ruby user_age.rb
Output
Age: 25
Error: "twenty-five" violates constraints (type?(Integer, "twenty-five") failed)

The first thing you'll notice is that dry-types validates data at runtime, raising clear errors when values don't match expected types. This provides a safety net that pure Ruby lacks, catching type mismatches before they cause problems deeper in your application.

Understanding dry-types

Dry-types provides a type system for Ruby that goes beyond basic type checking, offering constrained types, coercions, and compositions. Unlike Ruby's dynamic nature where type errors only surface at runtime when methods are called, dry-types validates data as soon as you assign it to a type, catching problems immediately rather than waiting for them to propagate through your application.

Built-in types

The library includes types for all common Ruby classes:

basic_types.rb
require_relative 'types'

# String type
name = Types::String["Alice"]
puts "Name: #{name}"

# Integer type
count = Types::Integer[42]
puts "Count: #{count}"

# Boolean type
active = Types::Bool[true]
puts "Active: #{active}"

# Array type
tags = Types::Array.of(Types::String)[["ruby", "dry-rb", "functional"]]
puts "Tags: #{tags.inspect}"

# Hash type
config = Types::Hash[{ debug: true, timeout: 30 }]
puts "Config: #{config.inspect}"

The bracket notation (Types::String["Alice"]) is how you apply a type to a value, essentially asserting "this value must be a String." Each type validates that the provided value matches the expected type, throwing a descriptive error if it doesn't. The Array.of method creates a parameterized array type where every element must match the specified type, providing element-level validation beyond Ruby's standard arrays.

Run the script to see the types in action:

 
ruby basic_types.rb
Output
Name: Alice
Count: 42
Active: true
Tags: ["ruby", "dry-rb", "functional"]
Config: {:debug=>true, :timeout=>30}

The output shows successful type validation for each Ruby class. When a type mismatch occurs, dry-types raises a Dry::Types::CoercionError with clear information about what went wrong, including the actual value, its type, and why it failed validation. This immediate feedback helps you catch data inconsistencies at the boundary of your application rather than deep within your business logic where they're harder to trace.

Optional and nilable types

Handle optional values explicitly rather than dealing with unexpected nils:

optional_types.rb
require_relative 'types'

# Optional type (can be nil)
OptionalString = Types::String.optional
puts "Optional with nil: #{OptionalString[nil].inspect}"
puts "Optional with value: #{OptionalString["hello"].inspect}"

# Make any type nilable using meta method
NilableInteger = Types::Integer.optional
puts "Nilable with nil: #{NilableInteger[nil].inspect}"
puts "Nilable with value: #{NilableInteger[42].inspect}"

# Default value for nil (using frozen string)
StringWithDefault = Types::String.constructor { |value| value.nil? ? "unknown" : value }
puts "Default with nil: #{StringWithDefault[nil]}"
puts "Default with value: #{StringWithDefault["Alice"]}"

Optional types make nil handling explicit in your code, eliminating ambiguity about whether a value can be nil. The optional modifier allows a type to accept nil without raising an error, making it clear in your type definitions which values are allowed to be absent. The constructor method provides a way to transform nil values into usable defaults, ensuring you always have a valid value even when none is provided.

Execute the code with the following command:

 
ruby optional_types.rb
Output
Optional with nil: nil
Optional with value: "hello"
Nilable with nil: nil
Nilable with value: 42
Default with nil: unknown
Default with value: Alice

The output demonstrates how optional types handle nil values gracefully. When nil is passed to an optional type, it returns nil without error. When a value is provided, it validates and returns that value. The constructor mechanism transforms nil into a predetermined fallback, which is particularly useful for configuration values or user inputs where you want sensible defaults without the mutability warnings that come with the default method.

Constrained types

Add runtime constraints to ensure values meet specific criteria:

constrained_types.rb
require_relative 'types'

# Must be greater than 0
PositiveInteger = Types::Integer.constrained(gt: 0)
puts "Valid positive integer: #{PositiveInteger[5]}"

begin
  PositiveInteger[-3]
rescue Dry::Types::ConstraintError => e
  puts "Error: #{e.message}"
end

# String with length constraints
Username = Types::String.constrained(min_size: 3, max_size: 20)
puts "Valid username: #{Username["alice"]}"

begin
  Username["ab"]
rescue Dry::Types::ConstraintError => e
  puts "Error: #{e.message}"
end

Constrained types move validation logic into your type definitions, making requirements explicit and catching invalid data immediately. The constrained method accepts various predicates like gt (greater than), min_size, and max_size that apply additional rules beyond basic type checking. This allows you to encode business rules directly into your types rather than scattering validation code throughout your application.

Run the program to see constraint validation:

 
ruby constrained_types.rb
Output
Valid positive integer: 5
Error: -3 violates constraints (gt?(0, -3) failed)
Valid username: alice
Error: "ab" violates constraints (min_size?(3, "ab") AND max_size?(20, "ab") failed)

The output shows how constraints validate values against specific rules. When a value violates a constraint, dry-types raises a detailed error message indicating which predicate failed and what value caused the failure. This immediate validation prevents invalid data from entering your system and provides clear feedback about what went wrong.

Creating data structures with dry-struct

Dry-struct builds on dry-types to create immutable data objects with typed attributes.

Basic struct definition

Define structured data with explicit types for each attribute:

user_struct.rb
require 'dry-struct'
require_relative 'types'

class User < Dry::Struct
  attribute :id, Types::Integer
  attribute :name, Types::String
  attribute :email, Types::String
  attribute :age, Types::Integer.optional
end

user = User.new(
  id: 1,
  name: "Alice Johnson",
  email: "alice@example.com",
  age: 28
)

puts "User: #{user.name} (#{user.email})"
puts "Age: #{user.age}"
puts "ID: #{user.id}"

# Demonstrate immutability
begin
  user.name = "Bob"
rescue NoMethodError => e
  puts "\nError: Cannot modify attributes - structs are immutable"
end

Dry-struct creates immutable objects by default, preventing accidental modifications and making your data flow more predictable. All attributes are validated against their types when the struct is instantiated, ensuring that invalid data never makes it into your objects.

Run the script to see struct behavior:

 
ruby user_struct.rb
Output
User: Alice Johnson (alice@example.com)
Age: 28
ID: 1

Error: Cannot modify attributes - structs are immutable

The output shows successful struct creation with type-validated attributes. The error at the end demonstrates immutability, a core feature that prevents modification after creation. This immutability guarantee makes structs thread-safe and eliminates entire classes of bugs related to unexpected state changes.

Validating data with dry-validation

Dry-validation provides a powerful DSL for defining validation rules that go beyond simple type checking. While dry-types validates individual values against their types, dry-validation handles complex scenarios like cross-field validation, conditional rules, and business logic that depends on multiple inputs. It's designed specifically for validating external data like form submissions, API payloads, or configuration files before they enter your application.

Basic validation contract

Create validation rules for your data:

user_contract.rb
require 'dry-validation'
require_relative 'types'

class UserContract < Dry::Validation::Contract
  params do
    required(:name).filled(:string)
    required(:email).filled(:string)
    required(:age).filled(:integer)
  end

  rule(:email) do
    unless /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i.match?(value)
      key.failure('must be a valid email address')
    end
  end

  rule(:age) do
    key.failure('must be at least 18') if value < 18
  end
end

contract = UserContract.new

result = contract.call(name: "Alice", email: "alice@example.com", age: 25)
puts "Valid - Success: #{result.success?}"

result = contract.call(name: "", email: "invalid", age: 15)
puts "Invalid - Success: #{result.success?}"
puts "Errors: #{result.errors.to_h}"

Validation contracts separate validation logic from your domain objects, making it easy to validate user input before creating structs or persisting data. The params block defines the basic structure and type requirements, while rule blocks add custom validation logic that can access the values and apply business rules.

Execute the validation script:

 
ruby user_contract.rb
Output
Valid - Success: true
Invalid - Success: false
Errors: {name: ["must be filled"], email: ["must be a valid email address"], age: ["must be at least 18"]}

The output shows how contracts distinguish between valid and invalid data. When validation succeeds, you get a result object with success? returning true and access to the validated values. When validation fails, you receive a detailed hash of errors organized by field, making it easy to display feedback to users or log validation failures.

Final thoughts

Dry-rb is a group of Ruby libraries that help you write cleaner, safer, and more organized code. It brings ideas from functional programming into Ruby and lets you use only the tools you need, such as checking data types, defining structured objects, or validating input.

With dry-types, you can make sure data matches the right type before it is used. With dry-struct, you can build reliable, unchangeable data objects. And with dry-validation, you can create clear rules to check user input or data from APIs.

Together, these tools help you catch errors early, make your code easier to understand, and keep your Ruby projects stable and maintainable.

Got an article suggestion? Let us know
Next article
Top 10 Ruby on Rails Alternatives for Web Development
Discover the top 10 Ruby on Rails alternatives. Compare Django, Laravel, Express.js, Spring Boot & more with detailed pros, cons & features.
Licensed under CC-BY-NC-SA

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.