Choosing a Nil‑Handling Strategy in Crystal: A Decision Guide
A decision guide for handling nilable values in Crystal: compare .try, .not_nil!, explicit guards, case expressions, and the stdlib try macro with a compact trade‑off table and a runnable demo.
03 Mar 2026, 20:55 UTC

The Decision
Crystal forces you to acknowledge that a value may be nil by wrapping the type in T? (or the equivalent union T | Nil). The compiler then requires an explicit guard before you call any method on that value. Your team must decide which guard style to adopt across the codebase, balancing safety, readability, and the occasional need to interoperate with dynamic code.
Constraints
- Static safety – the compiler must reject unguarded dereferences.
- Verbosity budget – excessive ceremony slows down reviews.
- Runtime cost – guards should not introduce hidden allocations.
- Dynamic boundaries – macros,
eval, or C bindings can bypass static checks.
Supported Options Compared
| Approach | Syntax | Compile‑time guarantee | Typical verbosity | Best fit |
|---|---|---|---|---|
Nilable type with .try | value.try &.method | Full – call only emitted when non‑nil | Low (single token) | Chaining optional calls, fluent APIs |
Nilable type with .not_nil! | value.not_nil!.method | Full – but raises at runtime if assumption wrong | Low | Internal invariants where nil is logically impossible |
Explicit if var.nil? guard | if var.nil?; ...; else var.method; end | Full – compiler narrows type in each branch | Medium (block) | Complex logic needing different handling for nil |
Union type T | Nil with pattern match | case var; when Nil; ...; when String; var.upcase; end | Full – exhaustive match enforced | Medium‑high | Multiple concrete types plus nil |
Object#try macro (stdlib) | try(value) { |v| v.upcase } | Full – expands to .try chain | Medium (block) | Legacy codebases migrating from Ruby‑style try |
Trade‑offs
Safety vs. Convenience
.try and .not_nil! are one‑liners, but .not_nil! re‑introduces a runtime exception if the invariant breaks. Use .not_nil! only when you can prove—by design or by preceding guard—that nil cannot appear.
Readability
An if var.nil? block makes the two paths obvious, which helps reviewers unfamiliar with the code. The case expression is even clearer when several non‑nil types coexist.
Performance
All guards compile to simple branch instructions; there is no heap allocation. The Object#try macro expands to the same .try chain, so performance is identical.
Dynamic Code Blind Spots
Macros that generate method bodies at compile time, or eval‑style runtime code, are not subject to the nilable type checker. You must manually validate any value crossing that boundary.
Concrete Implementation
The following module demonstrates each style. Save it as nil_demo.cr.
module NilDemo
# 1. Using .try – returns Nil when input is nil
def self.greet_try(name : String?) : String
name.try &.upcase || "HELLO GUEST"
end
# 2. Using .not_nil! – assumes non‑nil
def self.greet_bang(name : String?) : String
name.not_nil!.upcase
end
# 3. Explicit nil guard
def self.greet_if(name : String?) : String
if name.nil?
"HELLO GUEST"
else
name.upcase
end
end
# 4. Case on union type
def self.greet_case(name : String | Nil) : String
case name
when Nil
"HELLO GUEST"
when String
name.upcase
end
end
end
# Demo driver
puts NilDemo.greet_try(nil) # => HELLO GUEST
puts NilDemo.greet_try("alice") # => ALICE
begin
puts NilDemo.greet_bang(nil) # raises NilAssertionError
rescue ex : NilAssertionError
puts "bang failed as expected"
end
puts NilDemo.greet_if("bob") # => BOB
puts NilDemo.greet_case(nil) # => HELLO GUEST
Validation Steps
- Compile‑time check – run the compiler on a version that omits the guard:
The error confirms the compiler rejects unguarded calls.$ crystal build nil_demo.cr --error-on-warnings # Error: undefined method 'upcase' for Nil (compile-time type is String?) - Successful build – compile the guarded version:
No errors should appear.$ crystal build nil_demo.cr --release -o nil_demo - Runtime verification – execute the binary:
All paths behave as intended; the$ ./nil_demo HELLO GUEST ALICE bang failed as expected BOB HELLO GUEST.not_nil!path raises a controlled exception. - Optional memory safety check – run under Valgrind (Linux) to ensure no illegal memory accesses:
Expect zero invalid reads/writes.$ valgrind --leak-check=full ./nil_demo
Limitations & Practical Checks
- Macro‑generated code – if you write a macro that expands to a method body, the compiler cannot see the nilable type inside the macro expansion. Add explicit
.tryor guards inside the macro or validate the result after expansion. - C bindings – external libraries may return raw pointers that Crystal treats as non‑nilable. Wrap each binding call with a nil check before converting to a Crystal object.
- Team convention – document the chosen default (e.g., “prefer
.tryfor optional chaining, reserve.not_nil!for internal invariants”) in your style guide and enforce it with a linter rule if possible.
How to Verify Your Choice
Add a small test to your CI pipeline that compiles a file containing an unguarded String? method call. The build must fail. Then add the same file with a proper guard and confirm the build passes. This guarantees the compiler continues to enforce the policy you selected.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.