← Back to DevBytes

Functional Programming in Crystal

Introduction to Functional Programming in Crystal

Crystal is a statically typed, compiled language with a syntax heavily inspired by Ruby. While it is fundamentally object-oriented, Crystal provides first-class support for functional programming paradigms. This means you can write code that emphasizes immutability, pure functions, higher-order functions, and declarative data transformations — all while benefiting from Crystal's blazing fast performance and compile-time type checking.

Functional programming in Crystal is not an all-or-nothing proposition. You can adopt functional patterns incrementally, mixing them with object-oriented code where appropriate. This tutorial will walk you through the core concepts, practical techniques, and best practices for writing functional-style Crystal code.

What Makes Crystal Suitable for Functional Programming?

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Crystal bridges the gap between object-oriented and functional styles through several key features:

Why Functional Programming Matters in Crystal

1. Predictable and Testable Code

Pure functions — those that always return the same output for the same input and have no side effects — are inherently easier to reason about, test, and debug. In Crystal, embracing pure functions means fewer surprises at compile time and fewer bugs at runtime.

2. Safer Concurrency

While Crystal's concurrency model is based on communicating sequential processes (CSP) through channels, functional programming's emphasis on immutability eliminates shared mutable state, which is the root cause of most concurrency bugs. Immutable data can be safely shared across fibers without synchronization.

3. Expressive Data Transformations

Functional pipelines using map, filter, and reduce express complex data transformations in a way that reads almost like a specification. This declarative style is more maintainable than imperative loops with accumulating variables.

4. Compile-Time Optimizations

Crystal's compiler can aggressively optimize functional code. Pure functions with no side effects can be evaluated at compile time. Method chains on arrays can sometimes be fused. The type system verifies correctness before execution.

Core Building Blocks: Blocks, Procs, and Lambdas

Understanding the difference between blocks, procs, and lambdas is essential for functional programming in Crystal. They represent callable objects but have subtle differences in behavior.

Blocks

Blocks are the most common way to pass anonymous functions in Crystal. They are not first-class objects — you cannot store a block directly in a variable — but they are incredibly convenient and performant because the compiler can inline them.

# Block passed to a method using do...end syntax
numbers = [1, 2, 3, 4, 5]

doubled = numbers.map do |n|
  n * 2
end

puts doubled.inspect # => [2, 4, 6, 8, 10]

# Block using curly brace syntax (preferred for single expressions)
tripled = numbers.map { |n| n * 3 }
puts tripled.inspect # => [3, 6, 9, 12, 15]

# A method that accepts a block with yield
def with_timing(&block)
  start = Time.monotonic
  result = yield
  elapsed = Time.monotonic - start
  puts "Execution took #{elapsed.total_milliseconds.round(2)}ms"
  result
end

with_timing do
  sleep 0.1
  "done"
end
# Output: Execution took ~100ms
# Returns: "done"

Procs — First-Class Function Objects

Procs are first-class function objects that can be stored in variables, passed as arguments, and returned from other functions. They are created using the -> syntax or by capturing a block.

# Creating a Proc with the arrow syntax
square = ->(x : Int32) { x * x }
puts square.call(5) # => 25

# Procs can be stored in collections
operations = [
  ->(x : Int32) { x + 1 },
  ->(x : Int32) { x * 2 },
  ->(x : Int32) { x ** 3 }
]

# Apply all operations to a value
results = operations.map { |op| op.call(10) }
puts results.inspect # => [11, 20, 1000]

# Capturing a block as a Proc using the & prefix
def create_multiplier(factor : Int32)
  # Returns a Proc that captures 'factor'
  ->(x : Int32) { x * factor }
end

multiply_by_7 = create_multiplier(7)
puts multiply_by_7.call(6) # => 42

# A method that explicitly accepts a Proc parameter
def apply_twice(func : Proc(Int32, Int32), value : Int32)
  func.call(func.call(value))
end

increment = ->(x : Int32) { x + 1 }
puts apply_twice(increment, 5) # => 7

Lambdas — Procs with Strict Arity

In Crystal, lambdas are created with the same -> syntax but have stricter argument checking. Unlike Ruby, Crystal's type system enforces arity at compile time for all Proc types, making this distinction less critical but still important for understanding type signatures.

# Lambda with explicit type annotation for clarity
greet = ->(name : String) { "Hello, #{name}!" }

# Crystal's compiler enforces correct argument count
# This would be a compile-time error:
# greet.call("Alice", "extra_arg")  # Error: wrong number of arguments

# Storing lambda in a variable with type inference
processor : Proc(String, Int32) = ->(text : String) { text.size }
puts processor.call("functional") # => 10

Higher-Order Functions and the Enumerable API

Crystal's Enumerable module provides a rich set of higher-order functions that operate on collections. These methods accept blocks, procs, or lambdas to perform transformations, filtering, and aggregation.

Map — Transform Every Element

users = [
  {name: "Alice", age: 30},
  {name: "Bob",   age: 25},
  {name: "Carol", age: 35}
]

# Extract just the names using map
names = users.map { |user| user[:name] }
puts names.inspect # => ["Alice", "Bob", "Carol"]

# Map with index
numbered_names = users.map_with_index do |user, i|
  "#{i + 1}. #{user[:name]}"
end
puts numbered_names.inspect # => ["1. Alice", "2. Bob", "3. Carol"]

Select and Reject — Filter Collections

numbers = [5, 12, 8, 3, 25, 16, 9, 4]

# Select elements that match a predicate
even_numbers = numbers.select { |n| n.even? }
puts even_numbers.inspect # => [12, 8, 16, 4]

# Reject elements that match (inverse of select)
odd_numbers = numbers.reject { |n| n.even? }
puts odd_numbers.inspect # => [5, 3, 25, 9]

# Select with complex predicate using a Proc
is_prime = ->(n : Int32) : Bool {
  return false if n < 2
  (2..Math.sqrt(n).to_i).none? { |i| n % i == 0 }
}

primes = numbers.select { |n| is_prime.call(n) }
puts primes.inspect # => [5, 3]

Reduce — Aggregate to a Single Value

# Sum all numbers using reduce
total = [1, 2, 3, 4, 5].reduce(0) { |acc, n| acc + n }
puts total # => 15

# Find the maximum value
max = [7, 2, 9, 4, 11, 3].reduce(Int32::MIN) do |best, n|
  n > best ? n : best
end
puts max # => 11

# Reduce without initial value (uses first element)
words = ["functional", "programming", "in", "crystal"]
longest = words.reduce { |longest_so_far, word|
  word.size > longest_so_far.size ? word : longest_so_far
}
puts longest # => "programming"

# Building a hash with reduce
frequencies = ["apple", "banana", "apple", "orange", "banana", "apple"]
  .reduce(Hash(String, Int32).new(0)) { |hash, fruit|
    hash[fruit] += 1
    hash
  }
puts frequencies.inspect # => {"apple" => 3, "banana" => 2, "orange" => 1}

Flat Map — Transform and Flatten

# Expand each element into multiple values
pairs = [1, 2, 3].flat_map { |n| [n, n * 10] }
puts pairs.inspect # => [1, 10, 2, 20, 3, 30]

# Practical example: extract tags from articles
articles = [
  {title: "FP in Crystal", tags: ["functional", "crystal"]},
  {title: "Web Dev",       tags: ["http", "crystal"]},
  {title: "Concurrency",   tags: ["csp", "functional"]}
]

all_tags = articles.flat_map { |article| article[:tags] }
puts all_tags.inspect # => ["functional", "crystal", "http", "crystal", "csp", "functional"]

# Get unique tags by chaining with uniq
unique_tags = articles.flat_map { |a| a[:tags] }.uniq
puts unique_tags.inspect # => ["functional", "crystal", "http", "csp"]

Composing Multiple Operations

# Method chaining creates elegant data pipelines
transactions = [150, -45, 200, -80, 350, -20, 600, -100]

result = transactions
  .select { |t| t > 0 }           # Keep only deposits
  .map { |t| t * 1.05 }           # Apply 5% bonus
  .reject { |t| t < 100 }         # Filter out small amounts
  .reduce(0) { |sum, t| sum + t } # Sum everything

puts result # => 1312.5

# The same pipeline using a more functional style
class TransactionProcessor
  def self.process(transactions : Array(Int32)) : Float64
    transactions
      .select(&.positive?)
      .map { |t| t.to_f64 * 1.05 }
      .select(&.>(100.0))
      .sum
  end
end

puts TransactionProcessor.process(transactions) # => 1312.5

Pattern Matching for Functional Control Flow

Crystal's case expression is a powerful pattern matching tool that aligns beautifully with functional programming. It allows destructuring of complex data types and dispatching based on structure rather than just equality.

# Pattern matching on a union type
class Success(T)
  getter value : T
  def initialize(@value : T); end
end

class Failure(E)
  getter error : E
  def initialize(@error : E); end
end

alias Result(T, E) = Success(T) | Failure(E)

def handle_result(result : Result(Int32, String))
  case result
  when Success
    "Got value: #{result.value}"
  when Failure
    "Error occurred: #{result.error}"
  end
end

puts handle_result(Success.new(42))     # => "Got value: 42"
puts handle_result(Failure.new("oops")) # => "Error occurred: oops"
# Pattern matching with tuple destructuring
def describe_point(point : Tuple(Int32, Int32))
  case point
  when {0, 0}
    "Origin"
  when {x, 0} if x > 0
    "On positive X-axis at #{x}"
  when {0, y} if y > 0
    "On positive Y-axis at #{y}"
  when {x, y} if x == y
    "On diagonal at #{x}, #{y}"
  else
    "Point at #{point[0]}, #{point[1]}"
  end
end

puts describe_point({0, 0})   # => "Origin"
puts describe_point({5, 0})   # => "On positive X-axis at 5"
puts describe_point({3, 3})   # => "On diagonal at 3, 3"
puts describe_point({2, 7})   # => "Point at 2, 7"
# Recursive list processing with pattern matching
def sum_list(list : Array(Int32)) : Int32
  case list
  when [] of Int32
    0
  when [head, *tail]
    head + sum_list(tail)
  end
end

puts sum_list([1, 2, 3, 4, 5]) # => 15

# More complex recursive example: flatten nested arrays
def flatten_nested(arr : Array(Int32 | Array(Int32))) : Array(Int32)
  case arr
  when [] of Int32 | Array(Int32)
    [] of Int32
  when [head : Int32, *tail]
    [head] + flatten_nested(tail)
  when [head : Array(Int32), *tail]
    flatten_nested(head) + flatten_nested(tail)
  end
end

nested = [1, [2, 3, [4]], 5, [6, [7, 8]]]
# Note: this works with properly typed nested arrays

Immutability and Value Objects

Immutability is a cornerstone of functional programming. In Crystal, you can design immutable data structures using Struct (which has value semantics) or by convention with classes that never mutate after construction.

# Immutable struct — automatically has value semantics
record Point, x : Float64, y : Float64 do
  def distance_to(other : Point) : Float64
    dx = x - other.x
    dy = y - other.y
    Math.sqrt(dx * dx + dy * dy)
  end

  # Returns a NEW point instead of mutating
  def translate(dx : Float64, dy : Float64) : Point
    Point.new(x + dx, y + dy)
  end
end

origin = Point.new(0.0, 0.0)
moved = origin.translate(3.0, 4.0)
puts origin.inspect # => Point(@x=0.0, @y=0.0) — unchanged!
puts moved.inspect  # => Point(@x=3.0, @y=4.0)
puts origin.distance_to(moved) # => 5.0
# Immutable collection patterns
def add_element(collection : Array(Int32), element : Int32) : Array(Int32)
  # Returns a new array instead of modifying the original
  collection + [element]
end

original = [1, 2, 3]
extended = add_element(original, 4)
puts original.inspect # => [1, 2, 3] — original unchanged
puts extended.inspect # => [1, 2, 3, 4]

# Using frozen collections (compile-time constants)
DEFAULT_CONFIG = {
  timeout: 30,
  retries: 3,
  protocol: "https"
}.freeze

# Attempting to modify a frozen hash raises at compile/runtime
# DEFAULT_CONFIG[:timeout] = 60  # This would cause an error
# Building immutable data pipelines with method chaining
class Pipeline
  def initialize(@data : Array(Int32)); end

  def filter(&block : Int32 -> Bool) : Pipeline
    Pipeline.new(@data.select { |x| block.call(x) })
  end

  def transform(&block : Int32 -> Int32) : Pipeline
    Pipeline.new(@data.map { |x| block.call(x) })
  end

  def to_a : Array(Int32)
    @data
  end
end

result = Pipeline.new([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
  .filter(&.even?)
  .transform { |n| n * n }
  .filter { |n| n > 20 }
  .to_a

puts result.inspect # => [36, 64, 100]

Recursion and Tail Call Optimization

Recursion replaces traditional loops in pure functional programming. Crystal supports recursion and, while it doesn't guarantee tail call optimization in all cases, the compiler can optimize certain recursive patterns. Understanding recursion is vital for functional problem-solving.

# Classic recursive factorial
def factorial(n : Int32) : Int64
  if n <= 1
    1_i64
  else
    n.to_i64 * factorial(n - 1)
  end
end

puts factorial(5)  # => 120
puts factorial(10) # => 3628800

# Tail-recursive factorial (accumulator pattern)
def factorial_tail(n : Int32, acc : Int64 = 1_i64) : Int64
  if n <= 1
    acc
  else
    factorial_tail(n - 1, acc * n.to_i64)
  end
end

puts factorial_tail(10) # => 3628800
# Recursive tree traversal
class TreeNode
  getter value : Int32
  getter left : TreeNode?
  getter right : TreeNode?

  def initialize(@value : Int32, @left : TreeNode? = nil, @right : TreeNode? = nil); end
end

# In-order traversal returning all values
def inorder(node : TreeNode?) : Array(Int32)
  case node
  when nil
    [] of Int32
  else
    inorder(node.left) + [node.value] + inorder(node.right)
  end
end

tree = TreeNode.new(5,
  TreeNode.new(3, TreeNode.new(2), TreeNode.new(4)),
  TreeNode.new(8, TreeNode.new(7), TreeNode.new(9))
)

puts inorder(tree).inspect # => [2, 3, 4, 5, 7, 8, 9]

# Recursive sum of all node values
def tree_sum(node : TreeNode?) : Int32
  case node
  when nil
    0
  else
    node.value + tree_sum(node.left) + tree_sum(node.right)
  end
end

puts tree_sum(tree) # => 38

Partial Application and Currying Concepts

While Crystal doesn't have built-in currying syntax like some functional languages, you can achieve partial application by returning Procs that capture arguments. This technique lets you build specialized functions from general ones.

# Partial application by returning a Proc
def partial_multiply(x : Int32)
  ->(y : Int32) { x * y }
end

double = partial_multiply(2)
triple = partial_multiply(3)

puts double.call(10) # => 20
puts triple.call(10) # => 30

# Building a family of specialized functions
def make_formatter(prefix : String, suffix : String)
  ->(text : String) { "#{prefix}#{text}#{suffix}" }
end

html_bold = make_formatter("", "")
markdown_italic = make_formatter("*", "*")
json_key = make_formatter("\"", "\": ")

puts html_bold.call("Important")   # => "Important"
puts markdown_italic.call("note")  # => "*note*"
puts json_key.call("name")         # => "\"name\": "
# More advanced: creating a configurable pipeline
def create_validator(min_length : Int32, max_length : Int32, allowed_chars : Set(Char))
  ->(input : String) : Bool {
    input.size >= min_length &&
    input.size <= max_length &&
    input.chars.all? { |c| allowed_chars.includes?(c) }
  }
end

username_validator = create_validator(3, 20, Set.new("abcdefghijklmnopqrstuvwxyz0123456789_".chars))
hex_validator = create_validator(6, 6, Set.new("0123456789abcdef".chars))

puts username_validator.call("alice_42") # => true
puts username_validator.call("ab")       # => false (too short)
puts hex_validator.call("ff0033")        # => true
puts hex_validator.call("xyz123")        # => false (invalid chars)

Function Composition

Function composition combines simple functions into more complex ones. In Crystal, you can build composable function pipelines manually or with helper utilities.

# Manual composition by chaining calls
def compose_int_to_int(f : Proc(Int32, Int32), g : Proc(Int32, Int32))
  ->(x : Int32) { g.call(f.call(x)) }
end

add_one = ->(x : Int32) { x + 1 }
square  = ->(x : Int32) { x * x }

add_one_then_square = compose_int_to_int(add_one, square)
square_then_add_one = compose_int_to_int(square, add_one)

puts add_one_then_square.call(4) # => 25  (4+1=5, 5²=25)
puts square_then_add_one.call(4) # => 17  (4²=16, 16+1=17)
# Building a reusable composition operator
struct Function(T, U)
  getter proc : Proc(T, U)

  def initialize(@proc : Proc(T, U)); end

  # Compose with another function: self then other
  def >>(other : Function(U, V)) : Function(T, V) forall V
    Function(T, V).new(->(x : T) { other.proc.call(@proc.call(x)) })
  end

  def call(x : T) : U
    @proc.call(x)
  end
end

# Create composable functions
trim = Function(String, String).new(->(s : String) { s.strip })
downcase = Function(String, String).new(->(s : String) { s.downcase })
capitalize_words = Function(String, String).new(
  ->(s : String) { s.split.map(&.capitalize).join(" ") }
)

# Compose them in different orders
clean_text = trim >> downcase
formatted_text = downcase >> capitalize_words

puts clean_text.call("  Hello WORLD  ")     # => "hello world"
puts formatted_text.call("  Hello WORLD  ") # => "Hello World"

Practical Example: Building a Functional Data Processing Pipeline

Let's combine everything into a realistic example — processing a dataset of user records with functional transformations.

# Define our data types
record User, 
  id : Int32, 
  name : String, 
  email : String, 
  age : Int32, 
  active : Bool

# Sample dataset
users = [
  User.new(1, "Alice",   "alice@example.com",   28, true),
  User.new(2, "Bob",     "bob@test.com",        35, true),
  User.new(3, "Charlie", "charlie@example.com", 22, false),
  User.new(4, "Diana",   "diana@test.com",      31, true),
  User.new(5, "Eve",     "eve@example.com",     19, true),
  User.new(6, "Frank",   "frank@test.com",      45, false),
  User.new(7, "Grace",   "grace@example.com",   27, true),
]

# Functional pipeline for analytics
module UserAnalytics
  # Pure function: filter active users
  def self.active_users(users : Array(User)) : Array(User)
    users.select(&.active)
  end

  # Pure function: filter by domain
  def self.by_domain(users : Array(User), domain : String) : Array(User)
    users.select { |u| u.email.ends_with?("@#{domain}") }
  end

  # Pure function: age statistics
  def self.age_stats(users : Array(User)) : NamedTuple
    ages = users.map(&.age)
    {
      average: ages.sum.to_f64 / ages.size,
      min:     ages.min,
      max:     ages.max,
      count:   ages.size
    }
  end

  # Pure function: group by age bracket
  def self.age_brackets(users : Array(User)) : Hash(String, Array(User))
    users.group_by { |u|
      case u.age
      when 0..25 then "18-25"
      when 26..35 then "26-35"
      when 36..50 then "36-50"
      else "50+"
      end
    }
  end

  # Compose multiple operations into a report
  def self.domain_report(users : Array(User), domain : String) : String
    domain_users = users
      .then { |all| active_users(all) }
      .then { |active| by_domain(active, domain) }

    stats = age_stats(domain_users)
    
    "Domain: @#{domain}\n" +
    "Active users: #{domain_users.size}\n" +
    "Avg age: #{stats[:average].round(1)}\n" +
    "Age range: #{stats[:min]} - #{stats[:max]}"
  end
end

# Run the analytics
puts UserAnalytics.domain_report(users, "example.com")
# Output:
# Domain: @example.com
# Active users: 4
# Avg age: 23.5
# Age range: 19 - 28

# Group by age brackets
brackets = UserAnalytics.age_brackets(UserAnalytics.active_users(users))
brackets.each do |bracket, members|
  names = members.map(&.name).join(", ")
  puts "#{bracket}: #{names}"
end
# Output:
# 18-25: Charlie, Eve
# 26-35: Alice, Diana, Grace
# 36-50: Bob

Functional Error Handling

Functional programming prefers representing errors as values rather than raising exceptions. Crystal's union types make this pattern ergonomic and type-safe.

# Result type for functional error handling
module Result
  # Ok represents success with a value
  record Ok(T), value : T

  # Err represents failure with an error message
  record Err(E), error : E

  # Type alias for convenience
  alias ResultType(T, E) = Ok(T) | Err(E)

  # Create a result from a value
  def self.ok(value : T) : Ok(T) forall T
    Ok.new(value)
  end

  def self.err(error : E) : Err(E) forall E
    Err.new(error)
  end

  # Map over a result (transforms success value)
  def self.map(result : Ok(T) | Err(E), &block : T -> U) : Ok(U) | Err(E) forall T, E, U
    case result
    when Ok then Ok.new(block.call(result.value))
    when Err then result
    end
  end

  # Chain results together (flat_map / bind)
  def self.bind(result : Ok(T) | Err(E), &block : T -> Ok(U) | Err(E)) : Ok(U) | Err(E) forall T, E, U
    case result
    when Ok then block.call(result.value)
    when Err then result
    end
  end
end

# Usage: parsing and validating user input functionally
def parse_age(input : String) : Result::Ok(Int32) | Result::Err(String)
  begin
    age = input.to_i
    if age < 0 || age > 150
      Result.err("Age must be between 0 and 150")
    else
      Result.ok(age)
    end
  rescue ArgumentError
    Result.err("Invalid number format")
  end
end

def validate_email(email : String) : Result::Ok(String) | Result::Err(String)
  if email.includes?("@") && email.includes?(".")
    Result.ok(email)
  else
    Result.err("Invalid email format")
  end
end

# Combining validations with bind
def process_user(age_str : String, email : String) : String
  result = Result.bind(parse_age(age_str)) { |age|
    Result.bind(validate_email(email)) { |valid_email|
      Result.ok("User: age=#{age}, email=#{valid_email}")
    }
  }

  case result
  when Result::Ok   then "Success: #{result.value}"
  when Result::Err  then "Error: #{result.error}"
  end
end

puts process_user("25", "alice@example.com")  # => Success: User: age=25, email=alice@example.com
puts process_user("abc", "alice@example.com") # => Error: Invalid number format
puts process_user("30", "invalid")            # => Error: Invalid email format

Lazy Evaluation with Iterators

Crystal supports lazy evaluation through iterators, which compute values on demand rather than building intermediate collections. This is crucial for processing large datasets efficiently.

# Creating a lazy sequence using each method chaining
def fibonacci_sequence : Iterator(Int64)
  Iterator.new do
    a, b = 0_i64, 1_i64
    loop do
      yield a
      a, b = b, a + b
    end
  end
end

# Take first 10 Fibonacci numbers — computed lazily
fib = fibonacci_sequence.first(10)
puts fib.inspect # => [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

# Infinite lazy sequence of squares
def squares : Iterator(Int64)
  Iterator.new do
    n = 0_i64
    loop do
      yield n * n
      n += 1
    end
  end
end

# Get squares that are also divisible by 3 — first 5 of them
result = squares
  .select { |n| n % 3 == 0 }
  .reject { |n| n == 0 }
  .first(5)

puts result.inspect # => [9, 36, 81, 144, 225]
# Lazy pipeline for large file processing
def count_lines_matching(filename : String, pattern : Regex) : Int32
  File.each_line(filename)
    .select { |line| line.matches?(pattern) }
    .count
end

# Example: count error lines in a log file
# error_count = count_lines_matching("server.log", /ERROR/)

# Chaining lazy transformations
def top_n_longest_words(filename : String, n : Int32) : Array(String)
  File.each_line(filename)
    .flat_map(&.split)
    .select { |word| word.size > 3 }
    .uniq
    .sort_by(&.size)
    .reverse
    .first(n)
    .to_a
end

Best Practices for Functional Programming in Crystal

1. Prefer Immutable Data Structures

Use record (immutable structs) for value objects. When working with collections, return new collections instead of modifying existing ones. Use freeze on constants to prevent accidental mutation.

# Good: returns new array
def add_sorted(arr : Array(Int32), element : Int32) : Array(Int32)
  (arr + [element]).sort
end

# Avoid: mutates the input array
def add_sorted!(arr : Array(Int32), element : Int32) : Array(Int32)
  arr << element
  arr.sort!
end

2. Keep Functions Pure

A pure function depends only on its inputs and produces no side effects. This makes testing trivial and reasoning about code straightforward.

# Pure: result depends only on arguments
def calculate_tax(amount : Float64, rate : Float64) : Float64
  amount * rate
end

# Impure: depends on external mutable state
$global_tax_rate = 0.08

def calculate_tax_impure(amount : Float64) : Float64
  amount * $global_tax_rate  # Hidden dependency!
end

3. Use Type Signatures on Procs

Always annotate Proc types when

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles