Beginner

Control Flow in Crystal

Learn conditionals, case expressions, and loops in Crystal - from if/else and pattern-style case statements to while, until, and iterators with Docker-ready examples

Control flow determines the order in which your program executes statements - which branches it takes and how often it repeats work. Crystal inherits Ruby’s expressive, readable approach to control flow, but adds compile-time guarantees thanks to its static type system.

The standout characteristic of Crystal’s control flow is that almost everything is an expression that returns a value. An if does not just choose a branch; it evaluates to whatever that branch produces. This lets you assign the result of a conditional directly to a variable, keeping code concise. Crystal also leans on iterators and blocks rather than raw index-based loops, reflecting its multi-paradigm (object-oriented, functional, concurrent) design.

In this tutorial you will learn how to write conditionals with if/elsif/else and unless, branch on values and types with case/when, and repeat work with while, until, and Crystal’s iterator-based loops. You will also see how break and next give you fine-grained control inside loops.

Conditionals: if, unless, and Expressions

Crystal’s if/elsif/else works as you would expect, but remember that it returns a value. There is also unless (the inverse of if), a ternary operator, and a compact suffix form for single-line guards.

Create a file named control_flow.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
27
28
29
30
31
32
33
34
35
temperature = 72

# Standard if / elsif / else
if temperature > 85
  puts "It's hot outside"
elsif temperature > 60
  puts "The weather is pleasant"
else
  puts "Bring a jacket"
end

# unless is the inverse of if
logged_in = false
unless logged_in
  puts "Please log in"
end

# if is an expression - it returns a value you can assign
score = 88
grade = if score >= 90
          "A"
        elsif score >= 80
          "B"
        else
          "C"
        end
puts "Grade: #{grade}"

# Ternary operator for simple two-way choices
age = 20
status = age >= 18 ? "adult" : "minor"
puts "Status: #{status}"

# Suffix (modifier) form for a single guarded statement
puts "Even temperature" if temperature.even?

Because if is an expression, grade is assigned the value of whichever branch runs. The compiler infers its type as String since every branch produces a string. The suffix form (puts ... if ...) reads naturally for short, single-statement conditions.

Branching with case / when

Crystal’s case expression is far more powerful than a C-style switch. A single when can match multiple values, ranges, or even types - and like if, the whole case returns a value. When matching on a union type, case narrows the type inside each branch so the compiler knows exactly what you are working with.

Create a file named case_when.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
27
28
29
30
31
32
33
34
35
36
37
# Matching specific values (multiple values per branch)
day = "Saturday"
case day
when "Saturday", "Sunday"
  puts "It's the weekend!"
when "Monday"
  puts "Back to work"
else
  puts "A regular weekday"
end

# case as an expression, matching against ranges
number = 7
description = case number
             when 0
               "zero"
             when 1..9
               "single digit"
             when 10..99
               "double digit"
             else
               "large number"
             end
puts "#{number} is a #{description}"

# case matching on type - narrows each element of a union type
items = [42, "hello", 3.14]
items.each do |item|
  case item
  when Int32
    puts "Integer: #{item}"
  when String
    puts "String: #{item}"
  when Float64
    puts "Float: #{item}"
  end
end

The array items has the type Array(Int32 | String | Float64), so each item is a union. Inside each when branch, Crystal narrows the type - #{item} is known to be a specific type, which is what makes this both safe and convenient.

Loops: while, until, and Iterators

Crystal supports classic while and until loops, but idiomatic Crystal favors iterators like times and each, which take a block. These avoid off-by-one errors and read clearly. Use break to exit a loop early and next to skip to the following iteration.

Create a file named loops.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
27
28
29
30
31
32
33
34
35
# while loop - runs while the condition is true
count = 1
while count <= 3
  puts "Count: #{count}"
  count += 1
end

# until loop - runs until the condition becomes true
countdown = 3
until countdown == 0
  puts "T-minus #{countdown}"
  countdown -= 1
end

# times - repeat a block a fixed number of times (index starts at 0)
3.times do |i|
  puts "Iteration #{i}"
end

# each over a range
(1..3).each do |n|
  puts "Number #{n}"
end

# each over an array
["red", "green", "blue"].each do |color|
  puts "Color: #{color}"
end

# break and next for loop control
(1..10).each do |n|
  next if n.odd?  # skip odd numbers
  break if n > 6  # stop once we pass 6
  puts "Even: #{n}"
end

Notice that times yields a zero-based index, while a range like (1..3) yields its actual values. In the final loop, next skips every odd number and break halts iteration entirely once the value exceeds 6, so only 2, 4, and 6 are printed.

Running with Docker

You can run all three examples using the official Crystal image without installing anything locally:

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

# Run the conditionals example
docker run --rm -v $(pwd):/app -w /app crystallang/crystal:1.14.0 crystal run control_flow.cr

# Run the case / when example
docker run --rm -v $(pwd):/app -w /app crystallang/crystal:1.14.0 crystal run case_when.cr

# Run the loops example
docker run --rm -v $(pwd):/app -w /app crystallang/crystal:1.14.0 crystal run loops.cr

Expected Output

Running control_flow.cr produces:

The weather is pleasant
Please log in
Grade: B
Status: adult
Even temperature

Running case_when.cr produces:

It's the weekend!
7 is a single digit
Integer: 42
String: hello
Float: 3.14

Running loops.cr produces:

Count: 1
Count: 2
Count: 3
T-minus 3
T-minus 2
T-minus 1
Iteration 0
Iteration 1
Iteration 2
Number 1
Number 2
Number 3
Color: red
Color: green
Color: blue
Even: 2
Even: 4
Even: 6

Key Concepts

  • Everything is an expression - if, unless, and case all return values, so you can assign their result directly to a variable instead of mutating one inside each branch.
  • unless reads naturally - Use it for the inverse of an if when it makes the intent clearer, and prefer the suffix form (do_something if condition) for short single-statement guards.
  • case is pattern-style matching - A single when can match multiple values, ranges (1..9), or types, making it far more expressive than a traditional switch.
  • Type narrowing in case - When matching on a union type, Crystal narrows the type inside each branch, giving you compile-time safety with no manual casts.
  • Prefer iterators over manual counters - times, each, and ranges with blocks are idiomatic, avoid off-by-one mistakes, and read more clearly than index-based loops.
  • while vs until - while loops as long as a condition is true; until loops until a condition becomes true - choose whichever expresses your intent most directly.
  • break and next - break exits the loop immediately, while next skips the rest of the current iteration and continues with the following one.

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