Intermediate

Functions in Crystal

Learn how to define and call functions in Crystal - parameters, default and named arguments, scope, recursion, and higher-order functions with blocks and procs

Functions are how you package logic into reusable, named units. In Crystal, the keyword is def, and the syntax will feel instantly familiar to anyone who has written Ruby. But Crystal is a compiled, statically typed language, so functions carry extra power: the compiler can infer parameter and return types, catch type errors before your program ever runs, and still let you add explicit annotations when you want clarity or documentation.

Because Crystal is multi-paradigm - object-oriented, functional, and concurrent all at once - “functions” wear several hats. A bare def at the top level acts like a standalone function, while a def inside a class becomes a method. Crystal also embraces functional ideas: blocks, Proc objects, and a rich set of higher-order collection methods like map and select are everyday tools rather than advanced curiosities.

In this tutorial you’ll learn how to define functions, pass parameters with default and named arguments, understand local versus global scope, write recursive functions, and work with higher-order functions. Every example is runnable with Docker - no local Crystal install required.

Defining and Calling Functions

A function is defined with def, a name, an optional parameter list, and an end. Crystal returns the value of the last expression automatically, so an explicit return is usually optional. Type annotations are also optional thanks to global type inference, but adding them documents intent and helps the compiler give better error messages.

Create a file named functions.cr:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# A function with a typed parameter and return type
def greet(name : String) : String
  "Hello, #{name}!"   # last expression is returned automatically
end

# Multiple parameters
def add(a : Int32, b : Int32) : Int32
  a + b
end

# Annotations are optional - the compiler infers the types
def multiply(a, b)
  a * b
end

# An explicit early return using a one-line guard
def absolute(n : Int32) : Int32
  return -n if n < 0
  n
end

puts greet("Crystal")
puts add(3, 4)
puts multiply(6, 7)
puts absolute(-15)

Notice that greet has no return keyword - the string literal is the last expression, so it becomes the result. The absolute function shows Crystal’s expressive one-line if modifier for an early return.

Default and Named Arguments

Crystal supports default parameter values, so callers can omit arguments that have sensible defaults. It also supports named arguments, which make calls self-documenting and let you skip over earlier defaults without passing placeholder values.

Create a file named default_named_args.cr:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# 'exponent' defaults to 2 when not supplied
def power(base : Int32, exponent : Int32 = 2) : Int32
  base ** exponent
end

# Two parameters with defaults, callable by name
def format_price(amount : Float64, currency : String = "USD", decimals : Int32 = 2) : String
  "#{currency} #{amount.round(decimals)}"
end

puts power(5)                              # uses the default exponent of 2
puts power(2, 10)                          # explicit exponent
puts format_price(19.95)                   # all defaults
puts format_price(8.5, currency: "EUR")   # skip 'currency'? no - name it
puts format_price(100.0, decimals: 0)      # skip 'currency', set 'decimals'

The last two calls demonstrate the real advantage of named arguments: format_price(100.0, decimals: 0) keeps the default currency while overriding only decimals, with no need to repeat "USD" in the call.

Variable Scope

Variables created inside a function are local to that function - they cannot be seen from the outside, and each call gets its own fresh copies. For values that need to be shared everywhere, Crystal uses constants, which start with an uppercase letter and are visible across the whole program.

Create a file named scope.cr:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# subtotal and tax are local - they exist only during this call
def calculate_total(price : Float64, quantity : Int32) : Float64
  subtotal = price * quantity
  tax = subtotal * 0.08
  subtotal + tax
end

# Constants (uppercase) are accessible from any function
TAX_RATE = 0.08

def apply_tax(amount : Float64) : Float64
  amount + (amount * TAX_RATE)
end

puts calculate_total(10.0, 3)
puts apply_tax(50.0)

# Trying to read 'subtotal' here would be a compile error -
# it does not exist outside calculate_total:
# puts subtotal

Crystal favors local scope and constants over mutable global state. The commented-out puts subtotal line would fail to compile, which is exactly the kind of mistake the compiler catches for you.

Recursion

A recursive function calls itself, breaking a problem into smaller versions of the same problem until it reaches a base case. Crystal handles recursion naturally, and the static type system ensures the parameter and return types line up at every level of the call.

Create a file named recursion.cr:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# factorial(n) = n * (n-1) * ... * 1
def factorial(n : Int32) : Int32
  return 1 if n <= 1          # base case stops the recursion
  n * factorial(n - 1)        # recursive case
end

# Each Fibonacci number is the sum of the previous two
def fibonacci(n : Int32) : Int32
  return n if n < 2           # base cases: fib(0) = 0, fib(1) = 1
  fibonacci(n - 1) + fibonacci(n - 2)
end

puts factorial(5)
puts fibonacci(10)

Every recursive function needs a base case - here, n <= 1 for factorial and n < 2 for Fibonacci - otherwise it would recurse forever.

Higher-Order Functions: Blocks and Procs

This is where Crystal’s functional side shines. Functions can accept blocks of code via yield, accept Proc objects as ordinary parameters, and pass functions around as first-class values. The standard library leans on this heavily: collection methods like map and select take a block and apply it to each element.

Create a file named higher_order.cr:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# A method that takes a block and yields to it
def repeat(times : Int32, &)
  times.times { |i| yield i }
end

# A method that takes a Proc (a first-class function) as a parameter
def apply_twice(value : Int32, operation : Int32 -> Int32) : Int32
  operation.call(operation.call(value))
end

# A Proc literal - a function stored in a variable
double = ->(x : Int32) { x * 2 }

repeat(3) do |i|
  puts "Iteration #{i}"
end

puts apply_twice(5, double)   # double(double(5)) => 20

# Built-in higher-order methods on collections
numbers = [1, 2, 3, 4, 5]
evens = numbers.select { |n| n.even? }   # keep elements where the block is true
squared = numbers.map { |n| n * n }      # transform each element

puts evens
puts squared

The repeat method shows the block protocol: yield i runs the block passed at the call site, handing it the current index. The apply_twice method shows the Proc protocol: Int32 -> Int32 is the type of “a function from Int32 to Int32”, and .call invokes it. Both styles let you parameterize behavior, not just data.

Running with Docker

You can run every example above with the official Crystal image - no local installation needed.

1
2
3
4
5
6
7
8
9
# Pull the official Crystal image
docker pull crystallang/crystal:1.14.0

# Run each example (compiles and runs in one step)
docker run --rm -v $(pwd):/app -w /app crystallang/crystal:1.14.0 crystal run functions.cr
docker run --rm -v $(pwd):/app -w /app crystallang/crystal:1.14.0 crystal run default_named_args.cr
docker run --rm -v $(pwd):/app -w /app crystallang/crystal:1.14.0 crystal run scope.cr
docker run --rm -v $(pwd):/app -w /app crystallang/crystal:1.14.0 crystal run recursion.cr
docker run --rm -v $(pwd):/app -w /app crystallang/crystal:1.14.0 crystal run higher_order.cr

Expected Output

Running functions.cr:

Hello, Crystal!
7
42
15

Running default_named_args.cr:

25
1024
USD 19.95
EUR 8.5
USD 100.0

Running scope.cr:

32.4
54.0

Running recursion.cr:

120
55

Running higher_order.cr:

Iteration 0
Iteration 1
Iteration 2
20
2
4
1
4
9
16
25

Key Concepts

  • def defines functions and methods - the same keyword is used at the top level (a standalone function) and inside a class (a method).
  • Implicit return - a function returns the value of its last expression, so return is usually optional; use it for early exits like guard clauses.
  • Optional type annotations - global type inference means types are often inferred, but explicit annotations document intent and improve compiler error messages.
  • Default and named arguments - parameters can have default values, and named arguments let callers override only the arguments they care about while keeping other defaults.
  • Local scope by default - variables defined in a function are private to that call; share values through uppercase constants rather than globals.
  • Recursion needs a base case - a function that calls itself must have a stopping condition, or it will recurse indefinitely.
  • Functions are first-class - blocks (via yield) and Proc objects (Int32 -> Int32) let you pass behavior around, powering higher-order methods like map and select.

Running Today

All examples can be run using Docker:

docker pull crystallang/crystal:1.14.0
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining