# Gemfile
gem 'dry-types'
gem 'dry-struct'
require 'dry-types'
require 'dry-struct'
module Types
include Dry.Types()
end
# Define typed struct
class User < Dry::Struct
attribute :name, Types::String
attribute :email, Types::String
attribute :age, Types::Integer.optional
attribute :role, Types::String.enum('user', 'admin', 'moderator')
attribute :active, Types::Bool.default(true)
end
# Create instance
user = User.new(
name: 'John Doe',
email: 'john@example.com',
age: 30,
role: 'admin'
)
user.name # => "John Doe"
user.active # => true (default)
# Structs are immutable
user.name = "Jane" # NoMethodError
# Type errors caught at initialization
User.new(name: 123) # Dry::Struct::Error (type mismatch)
# Optional attributes
User.new(name: 'John', email: 'john@example.com', role: 'user')
# age is nil (optional)
# Custom types
module Types
Email = String.constrained(format: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i)
PositiveInteger = Integer.constrained(gt: 0)
Name = String.constrained(min_size: 2, max_size: 100)
end
class ValidatedUser < Dry::Struct
attribute :name, Types::Name
attribute :email, Types::Email
attribute :age, Types::PositiveInteger.optional
end
# Type coercion
module Types
CoercibleString = Coercible::String
CoercibleInteger = Coercible::Integer
end
class Post < Dry::Struct
transform_keys(&:to_sym) # Auto-convert string keys to symbols
attribute :id, Types::CoercibleInteger
attribute :title, Types::CoercibleString
attribute :published, Types::Params::Bool # Coerces "1", "true", etc.
end
Post.new(id: "123", title: 123, published: "1")
# Auto-coerces to: id: 123, title: "123", published: true
# Sum types (union types)
module Types
StringOrInteger = String | Integer
StringOrNil = String.optional # String | Nil
end
class FlexibleStruct < Dry::Struct
attribute :value, Types::StringOrInteger
end
FlexibleStruct.new(value: "hello") # OK
FlexibleStruct.new(value: 42) # OK
FlexibleStruct.new(value: true) # Error
# Nested structs
class Address < Dry::Struct
attribute :street, Types::String
attribute :city, Types::String
attribute :zip, Types::String
end
class UserWithAddress < Dry::Struct
attribute :name, Types::String
attribute :address, Address
attribute :tags, Types::Array.of(Types::String)
end
user = UserWithAddress.new(
name: 'John',
address: { street: '123 Main', city: 'NYC', zip: '10001' },
tags: ['developer', 'ruby']
)
# Gemfile
gem 'dry-monads'
gem 'dry-validation'
require 'dry-monads'
class CreateUser
include Dry::Monads[:result, :do]
def call(params)
values = yield validate(params)
user = yield create_user(values)
yield send_welcome_email(user)
Success(user)
end
private
def validate(params)
if params[:email].present? && params[:name].present?
Success(params)
else
Failure(:invalid_params)
end
end
def create_user(params)
user = User.create(params)
user.persisted? ? Success(user) : Failure(:user_creation_failed)
end
def send_welcome_email(user)
UserMailer.welcome_email(user).deliver_later
Success(true)
rescue => e
Failure(:email_failed)
end
end
# Usage
result = CreateUser.new.call(name: 'John', email: 'john@example.com')
case result
when Success
puts "User created: #{result.value!.name}"
when Failure
puts "Error: #{result.failure}"
end
# Pattern matching
result = CreateUser.new.call(params)
result.fmap { |user| UserSerializer.new(user).as_json }
.or { |error| { error: error } }
# Maybe monad
class UserFinder
include Dry::Monads[:maybe]
def call(id)
user = User.find_by(id: id)
user ? Some(user) : None()
end
end
finder = UserFinder.new
finder.call(123)
.fmap { |user| user.email }
.fmap { |email| email.upcase }
.value_or('No user found')
# Try monad
include Dry::Monads[:try]
result = Try { JSON.parse(data) }
result.to_result # Convert to Result monad
# Dry-validation
require 'dry-validation'
class UserContract < Dry::Validation::Contract
params do
required(:name).filled(:string)
required(:email).filled(:string)
required(:age).filled(:integer)
optional(:bio).maybe(:string)
end
rule(:email) do
key.failure('must be valid email') unless value.match?(/\A[\w+\-.]+@/)
end
rule(:age) do
key.failure('must be at least 18') if value < 18
end
rule(:name, :email) do
if User.exists?(name: values[:name], email: values[:email])
key.failure('user already exists')
end
end
end
contract = UserContract.new
result = contract.call(name: 'John', email: 'invalid', age: 15)
if result.success?
user = User.create(result.to_h)
else
result.errors.to_h
# => { email: ['must be valid email'], age: ['must be at least 18'] }
end
# Complex validation
class OrderContract < Dry::Validation::Contract
params do
required(:items).array(:hash) do
required(:product_id).filled(:integer)
required(:quantity).filled(:integer)
end
required(:shipping_address).hash do
required(:street).filled(:string)
required(:city).filled(:string)
required(:zip).filled(:string)
end
end
rule('items.each.quantity') do
key.failure('must be positive') if value <= 0
end
end