Crystal's annotation system lacked tight coupling between annotation types and their runtime representations. Framework authors building validation libraries, ORMs, or serialization tools had to maintain two separate type hierarchies:
- An annotation type for compile-time metadata
- A corresponding class/struct for runtime logic
This duplication was error-prone and verbose. Users couldn't easily convert annotations to runtime objects or leverage inheritance for annotation hierarchies.
- Typed Field Definitions - Define annotation fields with types and defaults, with compile-time validation
- Full Backwards Compatibility -
annotation Foo; endmust continue working unchanged - Annotation Inheritance - Child annotations inherit parent fields; lookups via parent type return children
- Runtime Instantiation - Automatically generate runtime classes from annotation definitions
- Field Visibility - Support private fields (macro-accessible only) vs public fields (appear on runtime class)
Fields are typed using macro type names (StringLiteral, BoolLiteral, etc.) rather than runtime types:
annotation NotBlank
message : StringLiteral = "must not be blank"
allow_nil : BoolLiteral = false
endRationale: Annotations exist at compile-time in the macro system. Using macro types makes the mental model consistent - you're defining what macro values the annotation accepts. The mapping to runtime types happens automatically during Instance class generation.
Generated runtime classes use a suffix naming convention: NotBlank annotation → NotBlankInstance class.
Alternatives Considered:
- Same name: Crystal's type system uses a single namespace per scope - annotations and classes can't share names
- Nested class (
NotBlank::Instance):AnnotationTypedoesn't inherit fromModuleType, so it can't contain nested types - Separate namespace: Would require architectural changes to Crystal's type registration
Rationale: Suffix naming is explicit, requires no compiler architecture changes, and clearly distinguishes the runtime representation from the compile-time annotation.
Generated Instance types are classes (heap-allocated) rather than structs (stack-allocated).
Rationale:
- Enables true inheritance (
NotBlankInstance < ConstraintInstance) - Type restrictions work naturally via class hierarchy
- Annotation instances are typically small and few - allocation overhead is negligible
- Structs in Crystal can't truly inherit (only include modules)
For ArrayLiteral and HashLiteral fields, the element types are extracted from the of clause in the default value:
annotation Constraint
groups : ArrayLiteral = [] of String # → Array(String)
scores : ArrayLiteral = [] of Int32 # → Array(Int32)
config : HashLiteral = {} of String => Int32 # → Hash(String, Int32)
endRationale: The type restriction ArrayLiteral doesn't carry element type information - it just says "this field accepts an array literal." The actual element type must come from somewhere, and the default value is the natural place. If no of clause is present, String is used as a sensible default.
Fields marked private are accessible in macros but excluded from the generated Instance class:
annotation FullName
private first_name : StringLiteral
private last_name : StringLiteral
name : StringLiteral = "#{first_name} #{last_name}" # computed from private fields
endRationale: Some annotation data is only needed at compile-time (for computation or validation) and shouldn't pollute the runtime API. Private fields provide this separation cleanly.
When looking up annotations via a parent type, child annotations are also returned:
{% for ann in ivar.annotations(Constraint) %}
# Returns NotBlank, Length, etc. - any annotation inheriting from Constraint
{% end %}Rationale: This enables polymorphic annotation processing - a validation framework can find all constraints without knowing every specific constraint type.
- AST -
AnnotationDefextended with optional fields array and superclass reference - Parser - Parses field definitions and inheritance syntax in annotation bodies
- Type System -
AnnotationTypegains field storage, inheritance tracking, and Instance class reference - Semantic Visitor - Generates Instance classes during annotation type processing
- Macro API -
Annotation#[]returns field defaults;to_runtime_representationgenerates instantiation code - Annotatable Module - Inheritance-aware annotation lookup
| Macro Type | Runtime Type |
|---|---|
StringLiteral |
String |
BoolLiteral |
Bool |
NumberLiteral |
Float64 |
CharLiteral |
Char |
SymbolLiteral |
Symbol |
NilLiteral |
Nil |
ArrayLiteral |
Array(T) where T from of clause |
HashLiteral |
Hash(K, V) where K, V from of clause |
The current implementation does not validate that annotation usages match field definitions at compile-time. Unknown fields don't raise errors, and type mismatches are only caught if the generated code fails to compile.
Why: Full validation would require changes to how annotations are processed during semantic analysis, touching code paths shared with backwards-compatible annotation handling.
All NumberLiteral fields become Float64 in the Instance class, regardless of whether the values are integers.
Why: Crystal's NumberLiteral AST node doesn't distinguish between integer and float literals at the type level. A more sophisticated implementation could inspect actual values or require explicit Int32Literal vs Float64Literal types.
If an ArrayLiteral field has no default value, it defaults to Array(String). There's no syntax to specify element types independently of the default.
Potential Future Syntax:
values : ArrayLiteral(Int32) # Not currently supportedDefault value expressions are not evaluated - they're used as-is. String interpolation in defaults (like "#{first_name}") only works because Crystal's macro system handles it during expansion.
Why: Full expression evaluation would require a compile-time interpreter for arbitrary Crystal expressions, significantly increasing complexity.
Any annotation with at least one public typed field gets an Instance class generated. There's no opt-out mechanism.
Why: The primary use case assumes you want runtime representations. An @[NoInstance] meta-annotation could be added if needed.
Fields without defaults are not enforced as "required" at the annotation usage site.
Why: This would require compile-time validation infrastructure that doesn't exist yet.
A future enhancement could validate annotation usages against field definitions:
- Error on unknown field names
- Error on type mismatches
- Error on missing required fields
Allow users to specify how macro types map to runtime types:
annotation Foo
@[RuntimeType(Int32)]
count : NumberLiteral = 0
endSupport arbitrary compile-time expressions in field defaults, evaluated via the macro interpreter:
annotation Config
timeout_ms : NumberLiteral = timeout_seconds * 1000
endMark annotations as abstract (no direct usage, only inheritance):
abstract annotation Constraint
# Can't use @[Constraint] directly, only children
endThis implementation provides a pragmatic solution to the annotation-to-runtime coupling problem. It prioritizes backwards compatibility and simplicity over completeness, making it suitable for incremental adoption. The suffix naming convention and class-based inheritance provide a clear, predictable model for framework authors while keeping the implementation complexity manageable.