Writing and Debugging Crystal Macros: A Practical Guide for Code Generation and DSLs
A step-by-step guide to writing, debugging, and safely using Crystal 1.8+ macros for zero-overhead code generation and DSLs — covering AST interpolation, hygiene, {% debug %}, and common pitfalls.
22 Jun 2026, 12:55 UTC

Desired Outcome
Create compile-time macros in Crystal 1.8+ that generate boilerplate code, build domain-specific languages (DSLs), or automate repetitive patterns — without runtime overhead — while keeping expansions debuggable and builds reproducible.
Prerequisites
- Crystal 1.8 or later installed (
crystal --versionto verify) - A minimal library project:
crystal init lib my_macros - Familiarity with Crystal's AST node types (
ASTNode,Call,Ident,Literal, etc.) - Editor with Crystal syntax highlighting (VS Code + Crystal Lang extension recommended)
Procedure: Build a Property Macro Step by Step
1. Define the Macro Skeleton
Open src/my_macros.cr and add a macro that generates a getter, setter, and instance variable for a given name and type:
macro property(name, type)
@{{name.id}} : {{type.id}}
def {{name.id}}
@{{name.id}}
end
def {{name.id}}=(@{{name.id}} : {{type.id}})
end
end
Key points:
{{name.id}}extracts the identifier string from the AST node{{type.id}}splices the type name into the generated code@{{name.id}}creates a hygienic instance variable — the macro system automatically namespaces it to avoid collisions
2. Invoke the Macro in a Class
class User
property name, String
property age, Int32
end
This expands at compile time to two instance variables, two getters, and two setters.
3. Debug the Expansion with {% debug %}
Insert {% debug %} inside the macro to print the current AST to stderr during compilation:
macro property(name, type)
{% debug %}
@{{name.id}} : {{type.id}}
# ...
end
Run crystal build src/my_macros.cr --emit=obj and observe the printed AST. This shows exactly what the macro sees and generates.
4. Inspect Argument Nodes with Introspection Helpers
Use these methods on macro arguments to make decisions at compile time:
{{type_of(expr)}}— returns the resolved type as a string{{expr.class.name}}— the AST node class (e.g., "Ident", "Call", "Literal"){{expr.stringify}}— source-code representation of the node{{pp expr}}— pretty-printed AST structure (useful in{% debug %}blocks)
Example: a macro that behaves differently for nullable types:
macro nullable_property(name, type)
{% if type_of(type).includes?("?") %}
@{{name.id}} : {{type.id}} = nil
{% else %}
@{{name.id}} : {{type.id}}? = nil
{% end %}
# getter/setter omitted for brevity
end
5. Iterate Over Type Fields for Bulk Generation
Combine {% for %} with @type.instance_vars to generate code for every field:
macro generate_serializers
{% for var in @type.instance_vars %}
def {{var.name.id}}_json
{{var.name.id}}.to_json
end
{% end %}
end
class Order
@id : String
@total : Float64
@items : Array(String)
generate_serializers
end
This creates id_json, total_json, and items_json methods automatically.
6. Declare Macro-Local Variables with {% var %}
Variables declared with {% var %} exist only during macro expansion and don't leak into generated code:
macro build_enum(values)
{% var members = [] of String %}
{% for v in values %}
{% members << v.stringify %}
{% end %}
enum {{members.join(", ")}}
end
build_enum [:pending, :processing, :completed, :failed]
7. Escape Hygiene Deliberately with {{@outer_var}}
When generated code must reference a variable from the surrounding scope, use the hygiene escape:
macro log_and_return(expr)
{% var temp = "__macro_temp_" + {{expr.id}} %}
{{@temp}} = {{expr}}
Log.info { "Returning: #{{{@temp}}}" }
{{@temp}}
end
Without {{@temp}}, the macro would create a fresh hygienic variable invisible to the caller.
Expected Checks
- Compilation succeeds:
crystal build src/my_macros.cr --emit=objexits with code 0 - Generated code is correct: Add
{% debug %}temporarily, verify output matches intent - Specs pass: Write specs for the generated API (
crystal spec) - No unintended capture: Check that macro-introduced identifiers don't conflict with user code (hygiene works by default)
- Compile time stays reasonable: If a macro generates hundreds of methods, consider runtime composition instead
Recovery Options
Macro Expansion Errors
Crystal emits compile-time stack traces pointing to the expansion site. If the error is cryptic:
- Add
{% debug %}before the failing line - Recompile with
crystal build --emit=objto see the AST at that point - Simplify the macro incrementally — split into smaller macros
Infinite Recursion
Recursive macros must have an explicit termination condition:
macro factorial(n)
{% if n <= 1 %}
1
{% else %}
{{n * factorial(n - 1)}}
{% end %}
end
Without the base case, the compiler hangs or crashes.
Non-Hermetic Build Commands
If you used {% `cmd` %} for build-time codegen and builds became non-reproducible:
- Replace with pure-Crystal logic or a separate code-generation step checked into version control
- If external commands are unavoidable, ensure they're deterministic (no timestamps, network calls, randomness)
Crystal 1.8+ {% if %} Narrowing Changes
Older macros using {% if condition %}...{% end %} for type narrowing now require an explicit {% else %} branch. Update by adding an empty {% else %} or restructuring.
Limitations to Keep in Mind
- Macros operate on AST only — no access to runtime values
- Cannot modify existing definitions after they're parsed
- Recursive macros need explicit termination
- Large expansions increase compile time and memory; prefer runtime composition for thousands of methods
- No interactive macro debugger — rely on
{% debug %},{{pp}}, and incremental compilation
Practical Verification
After adding macros, run the full test suite:
crystal spec
For generated types, use crystal tool hierarchy to inspect the resulting class structure. Compare with {% debug %} output to confirm the macro produced the expected AST.
Further Study
Read the standard library implementations in src/ — JSON::Serializable, YAML::Serializable, Enum, Flag, and delegate are all macro-driven and demonstrate idiomatic patterns. The official macro guide at crystal-lang.org/reference/syntax_and_semantics/macros.html and the Macro module API docs are the authoritative references.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.