Crystal’s type system is one of its most distinctive features: statically typed for safety and performance, yet rarely requiring explicit type annotations. The compiler infers types through global type inference, meaning you write code that feels dynamic while the compiler guarantees type safety at compile time. If you’re coming from Ruby, the syntax will feel familiar — but unlike Ruby, the compiler catches type errors before your program ever runs.
Crystal uses union types to handle cases where a value might be one of several types. This is especially important for nil safety: rather than allowing any variable to be nil at any time (the “billion dollar mistake”), Crystal makes nil an explicit part of the type system. A variable can only be nil if its type explicitly includes Nil.
In this tutorial you’ll learn Crystal’s primitive types, how type inference works in practice, how to declare constants, and how Crystal’s nil safety prevents a whole class of runtime errors.
Primitive Types and Basic Variables
Crystal provides the full set of numeric types you’d expect from a systems language, along with strings, booleans, and characters.
Create a file named variables.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
| # Integer types - Crystal infers Int32 by default for integer literals
age = 30
population = 8_000_000_000_i64 # i64 suffix for Int64
score = -15
byte_val = 255_u8 # u8 suffix for unsigned 8-bit
# Float types - Float64 by default
pi = 3.14159
gravity = 9.81_f32 # f32 suffix for Float32
# Boolean
is_compiled = true
is_slow = false
# Character - single quotes for Char
first_letter = 'A'
# String - double quotes
language = "Crystal"
multiline = "Line one\nLine two"
# String interpolation
puts "Language: #{language}"
puts "Age: #{age}"
puts "Pi: #{pi}"
puts "Is compiled: #{is_compiled}"
puts "First letter: #{first_letter}"
puts "Population: #{population}"
|
Running with Docker
1
2
3
4
5
| # Pull the official Crystal image
docker pull crystallang/crystal:1.14.0
# Run the variables example
docker run --rm -v $(pwd):/app -w /app crystallang/crystal:1.14.0 crystal run variables.cr
|
Expected Output
Language: Crystal
Age: 30
Pi: 3.14159
Is compiled: true
First letter: A
Population: 8000000000
Type Inference and Explicit Annotations
Crystal infers types automatically, but you can add explicit annotations when you want to be precise or when the compiler needs help.
Create a file named variables_types.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
| # Inferred types - compiler figures these out
count = 42 # Int32
ratio = 0.5 # Float64
name = "Crystal" # String
active = true # Bool
# Explicit type annotations
explicit_int : Int32 = 100
explicit_float : Float64 = 2.71828
explicit_str : String = "typed"
# Type of a variable - useful for understanding inference
puts typeof(count) # => Int32
puts typeof(ratio) # => Float64
puts typeof(name) # => String
puts typeof(explicit_float) # => Float64
# Crystal also supports underscores in numeric literals for readability
big_number = 1_000_000
hex_value = 0xFF # => 255
binary_val = 0b1010 # => 10
octal_val = 0o17 # => 15
puts "Big number: #{big_number}"
puts "Hex 0xFF: #{hex_value}"
puts "Binary 0b1010: #{binary_val}"
puts "Octal 0o17: #{octal_val}"
|
1
2
3
4
5
| # Pull the official Crystal image
docker pull crystallang/crystal:1.14.0
# Run the type inference example
docker run --rm -v $(pwd):/app -w /app crystallang/crystal:1.14.0 crystal run variables_types.cr
|
Expected Output
Int32
Float64
String
Float64
Big number: 1000000
Hex 0xFF: 255
Binary 0b1010: 10
Octal 0o17: 15
Constants and Immutability
Crystal uses SCREAMING_SNAKE_CASE for constants, which must be assigned at the point of declaration and cannot be reassigned.
Create a file named variables_constants.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
| # Constants - must be uppercase, cannot be reassigned
MAX_RETRIES = 3
PI = 3.14159265358979
APP_NAME = "CodeArchaeology"
SUPPORTED_LANGUAGES = ["Crystal", "Ruby", "Go"]
puts "App: #{APP_NAME}"
puts "Max retries: #{MAX_RETRIES}"
puts "Pi: #{PI}"
puts "Languages: #{SUPPORTED_LANGUAGES.join(", ")}"
# Frozen string literal - strings are mutable by default in Crystal,
# but you can express intent through constants
GREETING = "Hello"
# Regular variables can be reassigned
counter = 0
counter = counter + 1
counter += 1
puts "Counter after two increments: #{counter}"
# Crystal also supports multiple assignment
x, y, z = 1, 2, 3
puts "x=#{x}, y=#{y}, z=#{z}"
# Swap values without a temporary variable
x, y = y, x
puts "After swap: x=#{x}, y=#{y}"
|
1
2
3
4
5
| # Pull the official Crystal image
docker pull crystallang/crystal:1.14.0
# Run the constants example
docker run --rm -v $(pwd):/app -w /app crystallang/crystal:1.14.0 crystal run variables_constants.cr
|
Expected Output
App: CodeArchaeology
Max retries: 3
Pi: 3.14159265358979
Languages: Crystal, Ruby, Go
Counter after two increments: 2
x=1, y=2, z=3
After swap: x=2, y=1
Nil Safety and Union Types
Crystal’s nil safety is one of its most important features. A variable can only be nil if its type explicitly includes Nil as part of a union type.
Create a file named variables_nil.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
| # A regular String can never be nil - the compiler enforces this
name : String = "Crystal"
# name = nil # Compile error: can't assign Nil to String
# A nilable String uses the union type String | Nil (shorthand: String?)
maybe_name : String? = nil
puts "maybe_name is nil: #{maybe_name.nil?}"
maybe_name = "Now it has a value"
puts "maybe_name: #{maybe_name}"
# To use a nilable value, you must handle the nil case
# The compiler won't let you call String methods on String? without checking
if maybe_name
# Inside this block, the compiler knows maybe_name is String (not nil)
puts "Uppercase: #{maybe_name.upcase}"
end
# The not_nil! method asserts the value is not nil (raises at runtime if wrong)
safe_name = maybe_name.not_nil!
puts "Safe: #{safe_name}"
# Union types aren't limited to nil - a variable can hold multiple types
int_or_string : Int32 | String = 42
puts "Value: #{int_or_string}, type: #{typeof(int_or_string)}"
int_or_string = "now it's a string"
puts "Value: #{int_or_string}, type: #{typeof(int_or_string)}"
# Type narrowing with case/when
case int_or_string
when Int32
puts "Got an integer: #{int_or_string * 2}"
when String
puts "Got a string: #{int_or_string.upcase}"
end
|
1
2
3
4
5
| # Pull the official Crystal image
docker pull crystallang/crystal:1.14.0
# Run the nil safety example
docker run --rm -v $(pwd):/app -w /app crystallang/crystal:1.14.0 crystal run variables_nil.cr
|
Expected Output
maybe_name is nil: true
maybe_name: Now it has a value
Uppercase: NOW IT HAS A VALUE
Safe: Now it has a value
Value: 42, type: (Int32 | String)
Value: now it's a string, type: (Int32 | String)
Got a string: NOW IT'S A STRING
Type Conversions
Crystal does not do implicit type conversions between numeric types. You must convert explicitly using methods like to_i, to_f, to_s, and similar.
Create a file named variables_conversion.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
| # Numeric conversions
int_val = 42
float_val = 3.99
str_val = "100"
# Int to Float
as_float = int_val.to_f
puts "Int to Float: #{int_val} => #{as_float}"
# Float to Int - truncates (does not round)
as_int = float_val.to_i
puts "Float to Int (truncates): #{float_val} => #{as_int}"
# Int to String
as_str = int_val.to_s
puts "Int to String: #{int_val} => \"#{as_str}\""
# String to Int - returns Int32
parsed_int = str_val.to_i
puts "String to Int: \"#{str_val}\" => #{parsed_int}"
# String to Float
"3.14".to_f.tap { |f| puts "String to Float: #{f}" }
# Safe parsing - to_i? returns Int32? (nil if parsing fails)
bad_parse = "not_a_number".to_i?
puts "Bad parse result: #{bad_parse.nil? ? "nil (safe failure)" : bad_parse}"
good_parse = "256".to_i?
puts "Good parse result: #{good_parse}"
# Rounding a Float
puts "Round 3.99: #{float_val.round}"
puts "Floor 3.99: #{float_val.floor.to_i}"
puts "Ceil 3.01: #{3.01.ceil.to_i}"
|
1
2
3
4
5
| # Pull the official Crystal image
docker pull crystallang/crystal:1.14.0
# Run the type conversion example
docker run --rm -v $(pwd):/app -w /app crystallang/crystal:1.14.0 crystal run variables_conversion.cr
|
Expected Output
Int to Float: 42 => 42.0
Float to Int (truncates): 3.99 => 3
Int to String: 42 => "42"
String to Int: "100" => 100
String to Float: 3.14
Bad parse result: nil (safe failure)
Good parse result: 256
Round 3.99: 4.0
Floor 3.99: 3
Ceil 3.01: 4
Key Concepts
- Type inference — Crystal infers types from the value assigned; explicit annotations are optional but can improve clarity and catch mistakes early
- Static, strong typing — The compiler rejects type mismatches at compile time; there are no implicit numeric conversions
- Union types — A variable can hold values of multiple types (
Int32 | String); the compiler tracks which type it holds through control flow analysis - Nil safety —
nil is only valid where the type explicitly includes Nil (written as Type? or Type | Nil); the compiler prevents nil dereference errors - Type narrowing — Inside an
if value block, the compiler narrows a nilable type to its non-nil form automatically typeof — Returns the compile-time type of an expression, useful for understanding what the compiler has inferred- Safe conversion methods — Methods ending in
? (like to_i?) return a nilable type instead of raising on failure, enabling safe parsing - Numeric literal suffixes —
_i64, _f32, _u8, etc. let you specify exact numeric types when the default (Int32, Float64) isn’t what you need
Comments
Loading comments...
Leave a Comment