Skip to content

Instantly share code, notes, and snippets.

@Blacksmoke16
Last active December 18, 2025 00:49
Show Gist options
  • Select an option

  • Save Blacksmoke16/79a9767a2773be44233db1f7f4523bb3 to your computer and use it in GitHub Desktop.

Select an option

Save Blacksmoke16/79a9767a2773be44233db1f7f4523bb3 to your computer and use it in GitHub Desktop.
Crystal Annotations 2.0

Annotations 2.0 - Design Summary

Problem Statement

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:

  1. An annotation type for compile-time metadata
  2. 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.

Design Goals

  1. Typed Field Definitions - Define annotation fields with types and defaults, with compile-time validation
  2. Full Backwards Compatibility - annotation Foo; end must continue working unchanged
  3. Annotation Inheritance - Child annotations inherit parent fields; lookups via parent type return children
  4. Runtime Instantiation - Automatically generate runtime classes from annotation definitions
  5. Field Visibility - Support private fields (macro-accessible only) vs public fields (appear on runtime class)

Key Design Decisions

1. Field Type Restrictions Use Macro Types

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
end

Rationale: 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.

2. Suffix Naming for Generated Classes

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): AnnotationType doesn't inherit from ModuleType, 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.

3. Classes Over Structs for Instance Types

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)

4. Element Types Extracted from Default Values

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)
end

Rationale: 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.

5. Private Fields for Macro-Only Data

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
end

Rationale: 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.

6. Inheritance Lookup Includes Children

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.

Architecture Overview

Components Modified

  1. AST - AnnotationDef extended with optional fields array and superclass reference
  2. Parser - Parses field definitions and inheritance syntax in annotation bodies
  3. Type System - AnnotationType gains field storage, inheritance tracking, and Instance class reference
  4. Semantic Visitor - Generates Instance classes during annotation type processing
  5. Macro API - Annotation#[] returns field defaults; to_runtime_representation generates instantiation code
  6. Annotatable Module - Inheritance-aware annotation lookup

Type Mapping

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

Limitations

1. No Compile-Time Field Validation

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.

2. NumberLiteral Always Maps to Float64

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.

3. No Generic Array/Hash Types Without Defaults

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 supported

4. No Expression Evaluation in Defaults

Default 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.

5. Instance Classes Always Generated for Public Fields

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.

6. No Validation of Required Fields

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.

Future Considerations

Compile-Time Validation

A future enhancement could validate annotation usages against field definitions:

  • Error on unknown field names
  • Error on type mismatches
  • Error on missing required fields

Custom Type Mappings

Allow users to specify how macro types map to runtime types:

annotation Foo
  @[RuntimeType(Int32)]
  count : NumberLiteral = 0
end

Computed Fields with Full Expression Support

Support arbitrary compile-time expressions in field defaults, evaluated via the macro interpreter:

annotation Config
  timeout_ms : NumberLiteral = timeout_seconds * 1000
end

Abstract Annotations

Mark annotations as abstract (no direct usage, only inheritance):

abstract annotation Constraint
  # Can't use @[Constraint] directly, only children
end

Conclusion

This 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.

Annotations 2.0: Class-Based Annotations

Status

Steps 1-11 complete. Now implementing refinement: #runtime_type on literal types.

Current Task: Add #runtime_type to Literal Types

Add a runtime_type method to AST literal nodes that returns the Crystal type name they represent at runtime.

Implementation

File: src/compiler/crystal/syntax/ast.cr

Add runtime_type method to these literal classes:

Literal Class runtime_type returns
NilLiteral "Nil"
BoolLiteral "Bool"
NumberLiteral Based on kind: "Int8", "Int16", "Int32", "Int64", "Int128", "UInt8", "UInt16", "UInt32", "UInt64", "UInt128", "Float32", "Float64"
CharLiteral "Char"
StringLiteral "String"
SymbolLiteral "Symbol"
ArrayLiteral "Array"
HashLiteral "Hash"
RangeLiteral "Range"
RegexLiteral "Regex"
TupleLiteral "Tuple"
NamedTupleLiteral "NamedTuple"
ProcLiteral "Proc"

File: src/compiler/crystal/semantic/semantic_visitor.cr

Simplify literal_matches_restriction? to use literal.runtime_type instead of the large case statement.

Tests

Add tests in spec/compiler/semantic/annotation_spec.cr to verify the simplified validation still works correctly.


Original Goal

Allow classes/structs defined with annotation class or annotation struct to be used as annotations, making the runtime type the source of truth.

annotation class NotBlank < Constraint
  def initialize(@message : String = "must not be blank"); end
end

class User
  @[NotBlank]
  getter username : String
end

Design Decisions

  1. Runtime type is source of truth - no separate annotation definition needed
  2. annotation class / annotation struct syntax - native keyword combo, no meta-annotation needed
  3. Light validation at usage site:
    • Type is a valid annotation (traditional OR annotation class/struct)
    • Field name exists in some constructor overload
    • Shallow type match (runtime type → literal kind)
  4. Full validation deferred to to_runtime_representation via overload resolution
  5. Backwards compatible - traditional annotation Foo; end continues to work

Implementation Steps

Step 1: Extend ClassDef AST Node

File: src/compiler/crystal/syntax/ast.cr

Add annotation? property to ClassDef (around line 1510):

property? annotation : Bool  # alongside abstract? and struct?

Update initialize and clone_without_location to include the new property.

Step 2: Parser - Handle annotation class / annotation struct

File: src/compiler/crystal/syntax/parser.cr

Modify the annotation keyword handling (around line 1219):

when .annotation?
  check_type_declaration do
    check_not_inside_def("can't define annotation") do
      next_token_skip_space
      if @token.keyword?(:class)
        parse_class_def(is_annotation: true)
      elsif @token.keyword?(:struct)
        parse_class_def(is_struct: true, is_annotation: true)
      else
        # Back to parsing traditional annotation
        parse_annotation_def_after_keyword
      end
    end
  end

Also handle abstract annotation class / abstract annotation struct:

when .abstract?
  # ... existing abstract handling ...
  # Add check for annotation keyword after abstract
  if @token.keyword?(:annotation)
    next_token_skip_space
    if @token.keyword?(:class)
      parse_class_def(is_abstract: true, is_annotation: true)
    elsif @token.keyword?(:struct)
      parse_class_def(is_abstract: true, is_struct: true, is_annotation: true)
    end
  end

Update parse_class_def to accept is_annotation parameter:

def parse_class_def(is_abstract = false, is_struct = false, is_annotation = false, doc = nil)

Step 3: Type System - Track Annotation Classes

File: src/compiler/crystal/types.cr

Add property to track if a class/struct is an annotation type:

# In ClassType or NonGenericClassType
property? annotation_class : Bool = false

Step 4: Semantic - Process Annotation Classes

File: src/compiler/crystal/semantic/top_level_visitor.cr

When visiting ClassDef with annotation? == true, set type.annotation_class = true.

Step 5: Modify Annotation Resolution

File: src/compiler/crystal/semantic/semantic_visitor.cr

Update lookup_annotation to accept:

  • AnnotationType (existing behavior)
  • Class/struct with annotation_class? == true
def lookup_annotation(ann)
  type = lookup_type(ann.path)

  # Traditional annotation
  return type if type.is_a?(AnnotationType)

  # Annotation class/struct
  if type.responds_to?(:annotation_class?) && type.annotation_class?
    return type
  end

  ann.raise "#{ann.path} is not an annotation..."
end

Step 6: Light Validation at Usage Site

File: src/compiler/crystal/semantic/semantic_visitor.cr

Extend validate_annotation to handle annotation classes:

  • Extract constructor params from all initialize overloads
  • Validate both positional and named args:
    • Positional args: check arg at position N against param at position N in any overload
    • Named args: check that name exists as a param in any overload
  • Shallow type check: map runtime types to literal kinds (don't recurse into collections)

Type mapping for shallow validation:

Runtime Type Accepts Literal
Int8, Int16, Int32, Int64, UInt*, Float32, Float64 NumberLiteral
String StringLiteral
Bool BoolLiteral
Symbol SymbolLiteral
Char CharLiteral
Array(T) any ArrayLiteral (don't check T)
Hash(K, V) any HashLiteral (don't check K, V)
Nil NilLiteral
Union types any of the union members' accepted literals

Step 7: Macro API - Field Access with Defaults (Low Priority)

File: src/compiler/crystal/macros/methods.cr

Nice to have, don't spend too much time on this.

Update Annotation#[] to:

  1. Check named_args for explicit value (existing)
  2. If not found AND annotation type is an annotation class:
    • Look up constructor params across all overloads for matching name
    • Return default value if found

Step 8: Macro API - to_runtime_representation

File: src/compiler/crystal/macros/methods.cr

Add new method to_runtime_representation on Annotation:

when "to_runtime_representation"
  # Generates: AnnotationType.new(arg1, arg2, field: value, ...)
  # Splats positional args and double-splats named args
  Call.new(@path.clone, "new", args: @args.clone, named_args: @named_args.clone)

Step 9: Inheritance-Aware Lookups

File: src/compiler/crystal/annotatable.cr

Update annotation() and annotations() to also return annotations where the annotation class inherits from the requested type:

def annotations(annotation_type)
  results = @annotations.try &.[annotation_type]?.try(&.dup) || [] of Annotation

  # For annotation classes, also check inheritance
  @annotations.try &.each do |type, anns|
    next if type == annotation_type
    if type.responds_to?(:annotation_class?) && type.annotation_class?
      if type.inherits_from?(annotation_type)
        results.concat(anns)
      end
    end
  end

  results.empty? ? nil : results
end

Step 10: Formatter Support

File: src/compiler/crystal/tools/formatter.cr

Handle annotation class / annotation struct formatting to output the keyword combo correctly.

Step 11: Tests

File: spec/compiler/semantic/annotation_spec.cr

Add tests for:

  • Basic annotation class / annotation struct declaration
  • Using annotation classes as @[Foo]
  • Field name validation (unknown field errors)
  • Shallow type validation (type mismatch errors)
  • to_runtime_representation expansion
  • Inheritance lookups (annotations(Parent) returns children)
  • Backwards compatibility with traditional annotation Foo; end

Files to Modify

File Changes
src/compiler/crystal/syntax/ast.cr Add annotation? to ClassDef
src/compiler/crystal/syntax/parser.cr Handle annotation class/struct
src/compiler/crystal/syntax/to_s.cr Output annotation class/struct
src/compiler/crystal/types.cr Add annotation_class? property
src/compiler/crystal/semantic/top_level_visitor.cr Set annotation_class? on type
src/compiler/crystal/semantic/semantic_visitor.cr lookup_annotation + validate_annotation
src/compiler/crystal/macros/methods.cr [] defaults + to_runtime_representation
src/compiler/crystal/annotatable.cr Inheritance-aware lookups
src/compiler/crystal/tools/formatter.cr Format annotation class/struct
spec/compiler/semantic/annotation_spec.cr Tests

Resolved Design Questions

  1. Struct inheritance - annotation struct Foo < Bar allowed when Bar is an abstract struct. Also support abstract annotation struct declarations.

  2. Positional arg validation - validate both positional and named args:

    • Positional args validated against positional params by position in any overload
    • Named args validated by name against any overload
    • Both can be used together (e.g., @[Foo(1, name: "x")])

Open Questions

  1. Pure compile-time validation without runtime instantiation - traditional annotation Foo; end handles this use case.

  2. Error messages - when to_runtime_representation fails due to overload mismatch, consider a custom error message mentioning it's an annotation validation issue.

  3. Generic annotation classes - defer support for annotation class Foo(T) initially.


Potential Refinements (Post-Implementation)

Refinement A: self.new Constructor Support

Problem: Current validation only checks initialize methods. If an annotation class defines a custom self.new with different parameters, validation may reject valid annotations.

Solution: Also query annotation_type.metaclass.lookup_defs("new", lookup_ancestors_for_new: true) and validate against both initialize and self.new signatures.

Complexity: Medium - requires metaclass lookup and merging two sets of signatures.

Recommendation: Defer - custom self.new with different params than initialize is rare. If needed later, can be added.

Refinement B: #runtime_type on Literal Types

Problem: The shallow type validation in literal_matches_restriction? uses a large case statement mapping literal types to acceptable type names. This is verbose and doesn't leverage existing literal metadata.

Proposed Solution: Add #runtime_type method to literal AST nodes:

class StringLiteral
  def runtime_type : String
    "String"
  end
end

class NumberLiteral
  def runtime_type : String
    case kind
    when .i8?   then "Int8"
    when .i16?  then "Int16"
    when .i32?  then "Int32"
    # ... etc
    end
  end
end

class BoolLiteral
  def runtime_type : String
    "Bool"
  end
end
# ... etc for all literals

Benefits:

  1. Cleaner validation code - compare literal.runtime_type against restriction path
  2. Useful for macros - users can inspect what Crystal type a literal represents
  3. Better error messages - "expected String but got Int32"

Files to modify:

  • src/compiler/crystal/syntax/ast.cr - add runtime_type to literal classes
  • src/compiler/crystal/semantic/semantic_visitor.cr - simplify literal_matches_restriction?

Complexity: Low - straightforward method additions.

Refinement C: @[Annotation] Meta-Annotation Syntax

Current: Uses annotation class / annotation struct keyword combination.

Alternative: Use @[Annotation] meta-annotation before class/struct:

@[Annotation]
class NotBlank
  def initialize(@message : String = "must not be blank")
  end
end

Implementation approach: Handle at parser level (not semantic):

  1. When parsing annotations before class/struct
  2. Check if any annotation path is Annotation
  3. Set annotation? = true on the ClassDef
  4. Filter out @[Annotation] from the class's annotations list

Pros:

  • Follows Java/C# meta-annotation pattern users may expect
  • Uses existing annotation syntax, no new keyword combo

Cons:

  • Annotation becomes a reserved name with special parser meaning
  • Slightly less explicit than dedicated keyword

Files to modify:

  • src/compiler/crystal/syntax/parser.cr - detect @[Annotation] before class/struct
  • src/compiler/crystal/tools/formatter.cr - format @[Annotation] style
  • Tests

Complexity: Similar to current annotation class approach - both are parser-level.

Status: Deferred - current annotation class syntax works well.

Annotations 2.0 - Design (Issue #9802)

Problem Statement

Crystal's annotation system lacks tight coupling between annotation types and their runtime representations. Frameworks must maintain two separate types:

  1. An annotation for compile-time metadata
  2. A corresponding class/struct for runtime logic

Requirements

  1. Typed Field Definitions - Define fields with type (StringLiteral, etc.) and default value

    • Compile-time error for unknown fields
    • Compile-time error for wrong field types
  2. Full Backwards Compatibility - annotation Foo; end continues working

  3. Annotation Inheritance - Child inherits parent fields; .annotations(Parent) returns parent+children

  4. Field Visibility & Computed Fields

    • private fields: macro-accessible but NOT on runtime class
    • Public fields: appear on runtime class
    • Expression-based defaults can reference other fields
    • Compile-time error if required field not provided
  5. Runtime Instantiation via Macro - {{ ann.to_runtime_representation }} expands to NotBlank.new(...)

    • Compiler auto-generates classes for annotations with typed fields

Proposed Design

Syntax

annotation Constraint
  groups : ArrayLiteral = [] of String
end

annotation NotBlank < Constraint
  message : StringLiteral = "must not be blank"
  allow_nil : BoolLiteral = false
end

# Usage
@[NotBlank(message: "Name required")]
property name : String

Private Fields (Internal-only, not on runtime class)

annotation FullName
  # Private fields - macro-accessible but NOT on runtime class
  private first_name : StringLiteral
  private last_name : StringLiteral

  # Public field - computed from private fields, appears on runtime class
  name : StringLiteral = "#{first_name} #{last_name}"
end

# Usage
@[FullName(first_name: "John", last_name: "Doe")]
property person : Person

# Macro access - sees ALL fields (public and private)
{{ ann[:first_name] }}  # => "John"
{{ ann[:last_name] }}   # => "Doe"
{{ ann[:name] }}        # => "John Doe"

# Generated runtime class - only public fields
class FullName
  getter name : String
  def initialize(@name); end
end

Expression-based Defaults (referencing other fields)

annotation NotBlank < Constraint
  message : StringLiteral = "must not be blank"
  # Can reference parent fields in defaults
  groups : ArrayLiteral = ["validation"]  # overrides parent default
end

Note: Field default expressions are evaluated at compile-time when the annotation is applied. Expressions can reference fields defined earlier in the same annotation or inherited from parents.

Runtime Type Generation Strategy

Annotation types should be usable as runtime type restrictions:

def process(constraint : Constraint)
  # Accepts NotBlank, NotEmpty, etc.
end

Selected approach: Classes with getters only (immutable by convention)

  • All annotations → Non-abstract class (usable directly even if they have children)
  • Child annotations → Class inheriting from parent, with own getters + initialize calling super
  • Type restrictions work naturally via class inheritance
  • Heap allocated, but negligible cost for small annotation objects
# Generated from: annotation Constraint
class Constraint
  getter groups : Array(String)
  def initialize(@groups = [] of String); end
end

# Generated from: annotation NotBlank < Constraint
class NotBlank < Constraint
  getter message : String
  def initialize(@message = "must not be blank", groups = [] of String)
    super(groups)
  end
end

Example Usage

{% for ivar in @type.instance_vars %}
  {% if ann = ivar.annotation(NotBlank) %}
    # Current behavior still works
    puts {{ ann[:message] }}

    # New: compile-time error for undefined fields
    # {{ ann[:undefined] }}  # Error: NotBlank has no field 'undefined'

    # New: expand to runtime object
    validators << {{ ann.to_runtime_representation }}
    # Expands to: NotBlank.new(message: "Name required", allow_nil: false, groups: [] of String)
  {% end %}
{% end %}

Implementation Plan

1. AST Changes (src/compiler/crystal/syntax/ast.cr)

  • Add fields : Array(AnnotationField)? to AnnotationDef
  • Add superclass : Path? to AnnotationDef
  • New AnnotationField node with name, type, default

2. Parser Changes (src/compiler/crystal/syntax/parser.cr)

  • Parse field definitions in annotation body: name : Type = default
  • Parse inheritance syntax: annotation Foo < Bar

3. Type System (src/compiler/crystal/types.cr)

  • AnnotationType gains fields, superclass, all_fields (including inherited)

4. Semantic Validation (src/compiler/crystal/semantic/)

  • Validate annotation usage against defined fields
  • Check field types, required fields, unknown fields
  • Handle .annotations(Parent) to include children

5. Macro API (src/compiler/crystal/macros/methods.cr)

  • Annotation#to_runtime_representation - generates instantiation code
  • Annotation#[] - compile-time error for undefined fields (when annotation has typed fields)
  • AnnotationType#fields - access field definitions

6. Runtime Type Generation

  • All annotations → Non-abstract class with getters + initialize
  • Child annotations → Class inheriting from parent, with own getters + initialize calling super
  • Generated at same namespace as annotation

Files to Modify

File Changes
src/compiler/crystal/syntax/ast.cr:1569-1587 Add fields, superclass to AnnotationDef
src/compiler/crystal/syntax/parser.cr:1823-1843 Parse body and inheritance
src/compiler/crystal/types.cr:2881-2885 Add fields/inheritance to AnnotationType
src/compiler/crystal/semantic/top_level_visitor.cr Process annotation fields, validate usage
src/compiler/crystal/macros/methods.cr Add to_runtime_representation, field validation
src/compiler/crystal/annotatable.cr Handle parent/child annotation lookups

Design Decisions

  1. Nilable fields - Use question mark syntax: message : StringLiteral?
  2. NumberLiteral mapping - Always map to Float64 (safe default, can refine later)
  3. Type mapping summary:
    • StringLiteralString
    • BoolLiteralBool
    • NumberLiteralFloat64
    • SymbolLiteralSymbol
    • ArrayLiteralArray (untyped)
    • HashLiteralHash (untyped)
    • NilLiteral / ? suffix → nilable

Implementation Estimate

Reusable Infrastructure Found:

  • LinkAnnotation.from(ann) pattern (link.cr:7-102) - field validation with named args
  • MacroInterpreter (interpreter.cr) - compile-time expression evaluation including string interpolation
  • Type#ancestors / lookup_defs (types.cr:384-434) - inheritance traversal
  • Existing error handling: arg.raise, ann.wrong_number_of

Estimated Lines of Code:

Component Lines Notes
AST changes ~40-60 Model after existing Arg class
Parser ~60-100 Follow parse_arg patterns
Type system ~40-60 Adapt lookup_defs for fields
Semantic validation ~80-120 Reuse from(ann) pattern
Expression defaults ~50-80 Reuse MacroInterpreter
Macro API ~60-80 Extend existing methods
Runtime class gen ~80-120 Generate class hierarchy
Tests ~150-200

Total: ~550-850 lines

Time: 2-4 weeks (with moderate Crystal compiler familiarity)

Open Questions

(None - remaining details can be resolved during implementation)


Appendix: Alternative Approaches Considered

A. Annotation Syntax Alternatives

A1: Methods in Annotations Allow def in annotation body. Rejected: doesn't solve field validation or runtime expansion.

A2: Decorator (@[Annotation] class Foo) Make classes usable as annotations. Rejected: adds type system complexity, semantic ambiguity.

A3: Hybrid Syntax (annotation struct Foo) New keywords. Rejected: high complexity, new syntax to learn.

B. Runtime Type Generation Alternatives

B1: Flat Records with Module Inclusion (Not selected)

module Constraint; end
record NotBlank, message : String, groups : Array(String) do
  include Constraint
end
  • Pros: Value semantics, type restrictions work via module
  • Cons: No shared instance variables, fields duplicated in each child

B2: Union Type Alias (Not selected)

record NotBlank, message : String, groups : Array(String)
record NotEmpty, groups : Array(String)
alias AnyConstraint = NotBlank | NotEmpty
  • Pros: Simple, flat records
  • Cons: Must enumerate all subtypes manually, no true polymorphism

B3: Hybrid Classes + Union (Not selected) Generate class hierarchy AND union alias for exhaustive matching.

  • Pros: Most flexible
  • Cons: Most complex implementation

B4: Accept Limitation (Not selected) Keep flat records, let users manually create unions/modules if needed.

  • Pros: Simplest implementation
  • Cons: Poor DX for common use cases

C. Field Transformation Alternatives

C1: Full initialize with arbitrary code (Deferred for future consideration)

annotation FullName
  name : StringLiteral

  def initialize(first_name : StringLiteral, last_name : StringLiteral)
    @name = "#{first_name} #{last_name}"
  end
end

annotation NotBlank < Constraint
  message : StringLiteral

  def initialize(@message = "must not be blank")
    super(groups: ["validation"])
  end
end
  • Pros: Maximum flexibility, familiar syntax, supports complex transformations
  • Cons: Requires compile-time code evaluation (mini interpreter), significant implementation complexity
  • Status: Deferred - could be added later for advanced use cases

C2: Private fields with expression defaults (Selected)

annotation FullName
  private first_name : StringLiteral
  private last_name : StringLiteral
  name : StringLiteral = "#{first_name} #{last_name}"
end
  • Pros: Simpler implementation, covers 90% of use cases, clear semantics
  • Cons: Less flexible than full initialize for complex transformations
  • Status: Selected for initial implementation

Selected Approaches

  • Annotation syntax: Enhanced annotation keyword with typed fields
  • Field transformation: Private fields with expression-based defaults (C2)
  • Runtime generation: Class hierarchy with getters only (immutable by convention, non-abstract)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment