Getting Started

Table of contents

  1. Installation
  2. Your First Operation
  3. Defining Operations
    1. Base Classes
    2. Lifecycle Hooks
  4. Parameters
    1. Basic Definition
    2. Positional vs Named Parameters
    3. Type Constraints
    4. Parameter Coercion
  5. Executing Operations
    1. Multiple Ways to Execute
    2. Using Operations in Blocks
  6. Partial Application
    1. Chaining .with Calls
    2. PartiallyApplied vs Prepared
    3. Currying
  7. Pattern Matching
    1. Matching Results (with Dry::Monads)
  8. Error Handling
    1. Built-in Result Types (Default)
    2. Using Dry::Monads (Optional)
  9. Debugging
    1. Enabling .explain
    2. Using .explain
    3. Introspection
  10. Integrations Overview
    1. Rails
    2. Dry::Monads
    3. ActionPolicy
  11. Next Steps

A comprehensive guide to TypedOperation covering all core concepts.

Installation

Add to your Gemfile:

gem "typed_operation"

For Rails, run the generator to create an ApplicationOperation:

bin/rails g typed_operation:install

Your First Operation

class GreetUser < TypedOperation::Base
  param :name, String
  param :greeting, String, default: "Hello"

  def perform
    "#{greeting}, #{name}!"
  end
end

# Call directly
GreetUser.call(name: "Alice")
# => "Hello, Alice!"

# Or instantiate then call
operation = GreetUser.new(name: "Bob", greeting: "Hi")
operation.call
# => "Hi, Bob!"

Every operation must implement the perform method, which contains your business logic.

Defining Operations

Base Classes

TypedOperation provides two base classes:

Class Backed By Mutability ActionPolicy
TypedOperation::Base Literal::Struct Mutable ✓ Supported
TypedOperation::ImmutableBase Literal::Data Frozen ✗ Not supported

Use ImmutableBase when you want stronger immutability guarantees.

Lifecycle Hooks

Operations provide hooks for setup, validation, and result processing:

class ProcessPayment < TypedOperation::Base
  param :order, Order
  param :amount, Integer

  def prepare
    raise ArgumentError, "Order already paid" if order.paid?
  end

  def before_execute_operation
    @start_time = Time.current
    super
  end

  def perform
    PaymentGateway.charge(amount: amount, order_id: order.id)
  end

  def after_execute_operation(result)
    Rails.logger.info "Payment processed in #{Time.current - @start_time}s"
    super
  end
end

Key points:

  • prepare - Called after initialization; use for validation
  • before_execute_operation - Called before perform
  • after_execute_operation(result) - Called after perform; return value becomes operation result
  • Call super in hooks when you want subclasses to be able to hook in
  • Exceptions from hooks propagate to the caller

See API Reference: Lifecycle Hooks for complete documentation.

Parameters

Basic Definition

class CreateUser < TypedOperation::Base
  param :email, String                    # Required
  param :role, String, default: "member"  # With default
  param :bio, optional(String)            # Optional (can be nil)

  def perform
    User.create!(email:, role:, bio:)
  end
end

Positional vs Named Parameters

class SendEmail < TypedOperation::Base
  positional_param :recipient, String     # Positional argument
  param :subject, String                  # Keyword argument
  param :body, String

  def perform
    EmailService.send(to: recipient, subject:, body:)
  end
end

# Usage: positional first, then keywords
SendEmail.call("user@example.com", subject: "Welcome", body: "Thanks!")

Type Constraints

TypedOperation uses the literal gem for type checking:

class Example < TypedOperation::Base
  include Literal::Types

  param :name, String
  param :count, Integer
  param :active, _Boolean
  param :tags, _Array(String)
  param :data, _Hash(String, Integer)
  param :status, _Enum("pending", "active", "closed")
  param :user, _Nilable(User)
  param :id, _Union(Integer, String)
end

Types are checked at instantiation. Invalid types raise Literal::TypeError.

Parameter Coercion

Transform input values using &:method or a block:

class Calculate < TypedOperation::Base
  param :count, Integer, &:to_i
  param :price, Float, &:to_f
  param :tags, _Array(String) do |value|
    value.is_a?(String) ? value.split(",").map(&:strip) : value
  end

  def perform
    # count and tags are coerced before type checking
  end
end

Calculate.call(count: "42", price: "19.99", tags: "ruby, rails")
# count = 42, price = 19.99, tags = ["ruby", "rails"]

Executing Operations

Multiple Ways to Execute

# Class-level call (most common)
GreetUser.call(name: "Alice")

# Instantiate then call
op = GreetUser.new(name: "Alice")
op.call

Using Operations in Blocks

class Double < TypedOperation::Base
  param :n, Integer
  def perform; n * 2; end
end

[1, 2, 3].map { |n| Double.call(n: n) }
# => [2, 4, 6]

Partial Application

Fix some parameters while leaving others open:

class SendEmail < TypedOperation::Base
  param :to, String
  param :from, String
  param :subject, String
  param :body, String

  def perform
    EmailService.send(to:, from:, subject:, body:)
  end
end

# Create a reusable template
noreply = SendEmail.with(from: "noreply@example.com")

# Use it multiple times
noreply.call(to: "user@example.com", subject: "Welcome", body: "Thanks!")
noreply.call(to: "other@example.com", subject: "Update", body: "News...")

Chaining .with Calls

calc = SomeOperation.with(x: 10).with(y: 20).with(z: 30)
calc.call

PartiallyApplied vs Prepared

  • PartiallyApplied: Missing required parameters. Call raises MissingParameterError.
  • Prepared: All required parameters set. Ready to call.
partial = MyOp.with(a: 1)        # Missing required 'b'
partial.prepared?                # => false
partial.call(b: 2)               # Provide remaining params

prepared = MyOp.with(a: 1, b: 2)
prepared.prepared?               # => true
prepared.call                    # Execute immediately

Type checking is deferred until the operation is instantiated (when calling).

Currying

Transform an operation into single-argument functions:

class Add < TypedOperation::Base
  param :a, Integer
  param :b, Integer
  param :c, Integer
  def perform; a + b + c; end
end

Add.curry.(10).(20).(30)
# => 60

Pattern Matching

TypedOperation supports Ruby 3+ pattern matching:

class UserOp < TypedOperation::Base
  param :name, String
  param :role, String
  def perform; {name:, role:}; end
end

op = UserOp.new(name: "Alice", role: "admin")

case op
in UserOp[name:, role: "admin"]
  puts "Admin: #{name}"
in UserOp[name:, role:]
  puts "User: #{name} (#{role})"
end

Matching Results (with Dry::Monads)

result = CreateUser.call(email: "test@example.com")

case result
in Success(user)
  puts "Created: #{user.email}"
in Failure[:validation, errors]
  puts "Errors: #{errors.join(', ')}"
in Failure[code, message]
  puts "Failed: #{code} - #{message}"
end

Error Handling

TypedOperation supports two approaches: exceptions for unexpected errors, and Result types for expected failures.

Built-in Result Types (Default)

TypedOperation includes built-in Success and Failure types that work without additional dependencies:

class CreateUser < TypedOperation::Base
  include TypedOperation::Result::Mixin

  param :email, String

  def perform
    return Failure(:invalid_email) unless email.include?("@")
    Success(User.create!(email:))
  end
end

result = CreateUser.call(email: "test@example.com")
result.success?  # => true
result.value!    # => the user (raises UnwrapError on failure)
result.failure   # => nil (or the error on failure)

The built-in types support pattern matching:

case result
in TypedOperation::Result::Success[user]
  puts "Created: #{user.email}"
in TypedOperation::Result::Failure[error]
  puts "Failed: #{error}"
end

Using Dry::Monads (Optional)

For more features like Do notation, use Dry::Monads. You have two options:

Option 1: Include Dry::Monads directly (simplest, per-operation):

class CreateUser < TypedOperation::Base
  include Dry::Monads[:result]

  def perform
    # Success/Failure come from Dry::Monads
  end
end

Option 2: Configure the adapter globally (use with Result::Mixin):

# In an initializer
TypedOperation.configure do |config|
  config.result_adapter = :dry_monads
end

# Then in operations
class CreateUser < TypedOperation::Base
  include TypedOperation::Result::Mixin  # Now returns Dry::Monads types

  def perform
    # Success/Failure are now Dry::Monads types
  end
end

See Integrations: Dry::Monads for Do notation and advanced patterns.

See Best Practices for detailed strategies.

Debugging

Enabling .explain

Include TypedOperation::Explainable in your base operation:

class ApplicationOperation < TypedOperation::Base
  include TypedOperation::Explainable
end

Using .explain

Use .explain instead of .call to trace execution:

FulfillOrder.explain(order_id: 42)
# Output:
# FulfillOrder [1.23ms] ✓
# ├── ValidateOrder [0.15ms] ✓
# └── ProcessPayment [0.89ms] ✓
# => "Order 42 fulfilled"

Failures are marked with ✗ and show the error message.

Introspection

Query operation metadata at runtime:

MyOperation.positional_parameters        # => [:arg1, :arg2]
MyOperation.keyword_parameters           # => [:name, :email]
MyOperation.required_keyword_parameters  # => [:email]
MyOperation.optional_keyword_parameters  # => [:name]

partial = MyOperation.with(email: "test@example.com")
partial.prepared?  # => true/false

Integrations Overview

Rails

# Generate operations
bin/rails g typed_operation CreateUser email:string name:string

See Integrations for full details.

Dry::Monads

class CreateUser < ApplicationOperation
  include Dry::Monads[:result]
  include Dry::Monads::Do.for(:perform)

  param :email, String

  def perform
    user = yield validate_email
    yield send_welcome_email(user)
    Success(user)
  end
end

See Integrations for full details.

ActionPolicy

class DeletePost < ApplicationOperation
  include TypedOperation::ActionPolicyAuth

  param :post, Post
  param :current_user, User

  authorized_via :current_user, :post, to: :destroy?

  def perform
    post.destroy!
  end
end

See Integrations for full details.

Next Steps