How to Use Dry-rb for Functional Programming in Ruby
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:
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:
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:
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
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:
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
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:
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
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:
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
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:
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
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:
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
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.