Purpose

This guide provides comprehensive guidance on value transformations in LutaML Model, helping you choose the right approach for your transformation needs.

Value transformations are operations that convert data between different representations while preserving or deriving the underlying information. This guide distinguishes between value-level and model-level transformations and provides a decision framework for implementation.

Concepts

Value vs Model Distinction

Understanding the difference between values and models is crucial for choosing the right transformation approach.

Value (Atomic)

The smallest representation in a serialization format that cannot be decomposed further within that format.

Characteristics of Values
  • Atomic - Cannot be broken down into smaller parts in the serialization format

  • Self-contained - Represents a complete piece of information

  • Format-specific - May have different representations in different formats

  • "20241225" - A date string in YYYYMMDD format

  • 1234 - An integer

  • true - A boolean

  • "RGB(255,128,0)" - A color specification

  • "2024-12-25T10:30:00Z" - An ISO8601 datetime

Model (Composite)

A structure containing multiple attributes or components that can be accessed individually.

Characteristics of Models
  • Composite - Contains multiple distinct attributes

  • Mappable - Can use mapping rules to transform structure

  • Decomposable - Components can be accessed and mapped separately

  • {year: 2024, month: 12, day: 25} - Date components as separate attributes

  • {red: 255, green: 128, blue: 0} - Color components

  • {hour: 10, minute: 30, second: 0} - Time components

Example 1. Key Distinction

The fundamental difference is whether the data can be mapped (model) or must be transformed (value).

  • Mapping - Structural reorganization without computation

  • Transformation - Computational conversion or derivation

Example: Converting "20241225" to "25122024" requires transformation (string manipulation), while converting {year: 2024, month: 12, day: 25} to {day: 25, month: 12, year: 2024} only requires mapping.

Transformation Types

Value transformations fall into two main categories:

Format Conversion Transformations

Purpose: Convert the same information between different representations.

Characteristics: * Information is preserved * Typically bidirectional * No calculation required (only format manipulation)

Table 1. Examples of Format Conversion
From Format To Format Transformation Type

YYYYMMDD ("20241225")

DDMMYYYY ("25122024")

String reformatting

ISO8601 ("2024-12-25")

YYYYMMDD ("20241225")

Date component rearrangement

Hex color ("#FF8000")

RGB notation ("RGB(255,128,0)")

Numeric base conversion

Unix timestamp (1735128600)

ISO8601 ("2024-12-25T10:30:00Z")

Time representation conversion

Calculated Value Transformations

Purpose: Derive new information through computation.

Characteristics: * Information may be derived or aggregated * May be unidirectional or bidirectional * Requires calculation or algorithmic processing

Table 2. Examples of Calculated Transformations
From Value To Value Calculation Required

YYYYMMDD ("20241225")

YYYYWWDD ("20245203")

ISO week number calculation

Celsius (25.0)

Fahrenheit (77.0)

Temperature formula: F = C × 9/5 + 32

Distance + Speed

Duration

time = distance / speed

Date components

Day of year

Days since January 1st

Transformation Approaches

LutaML Model provides three distinct approaches for value transformations, each suited for different scenarios.

Approach 1: Custom Value Type Classes

Overview

Custom Value Type classes provide the most powerful and reusable transformation mechanism by creating dedicated type classes that inherit from Lutaml::Model::Type::Value.

Structure

class CustomValueType < Lutaml::Model::Type::Value
  # REQUIRED: Core conversion methods
  def self.cast(value)
    # Convert external/string value to internal Ruby object
    # This is called when assigning values to attributes
  end

  def self.serialize(value)
    # Convert internal Ruby object to generic serialization string
    # Used as fallback when format-specific methods not defined
  end

  # OPTIONAL: Format-specific deserialization
  def self.from_xml(value)
    # Parse XML string representation to internal object
  end

  def self.from_json(value)
    # Parse JSON string representation to internal object
  end

  def self.from_yaml(value)
    # Parse YAML string representation to internal object
  end

  # OPTIONAL: Format-specific serialization
  def to_xml
    # Serialize internal object to XML string
  end

  def to_json
    # Serialize internal object to JSON string
  end

  def to_yaml
    # Serialize internal object to YAML string
  end
end

When to Use

Use Custom Value Type classes when:

  • ✅ Bidirectional transformations are needed

  • ✅ Format-specific representations are required

  • ✅ Logic will be reused across multiple attributes or models

  • ✅ Complex parsing or serialization logic is involved

  • ✅ Type safety and encapsulation are important

  • ✅ Custom validation rules are needed

Advantages

  • Reusable - Define once, use across many attributes

  • Type-safe - Encapsulates all transformation logic in one place

  • Format-aware - Different logic per serialization format

  • Testable - Easy to unit test in isolation

  • Maintainable - Centralized logic, easier to modify

Complete Example: Bidirectional Date Formats

# Supports different date formats in different serialization formats
class MultiFormatDate < Lutaml::Model::Type::Date
  # Core conversion: any input to Date object
  def self.cast(value)
    case value
    when ::Date then value
    when ::String then parse_any_format(value)
    else
      super  # Delegate to parent class
    end
  end

  # Default serialization
  def self.serialize(value)
    value&.iso8601
  end

  # XML uses YYYYMMDD format
  def self.from_xml(value)
    return nil if value.nil? || value.empty?

    year = value[0..3].to_i
    month = value[4..5].to_i
    day = value[6..7].to_i

    ::Date.new(year, month, day)
  rescue ArgumentError
    nil
  end

  def to_xml
    value&.strftime("%Y%m%d")
  end

  # JSON uses DDMMYYYY format
  def self.from_json(value)
    return nil if value.nil? || value.empty?

    day = value[0..1].to_i
    month = value[2..3].to_i
    year = value[4..7].to_i

    ::Date.new(year, month, day)
  rescue ArgumentError
    nil
  end

  def to_json
    value&.strftime("%d%m%Y")
  end

  # YAML uses standard ISO8601
  def self.from_yaml(value)
    ::Date.parse(value.to_s)
  rescue ArgumentError
    nil
  end

  def to_yaml
    value&.iso8601
  end

  private

  def self.parse_any_format(str)
    # Try different formats intelligently
    from_xml(str) || from_json(str) || ::Date.parse(str)
  rescue
    nil
  end
end

# Usage in a model
class Event < Lutaml::Model::Serializable
  attribute :event_date, MultiFormatDate

  xml do
    root "event"
    map_element "eventDate", to: :event_date
  end

  json do
    map "eventDate", to: :event_date
  end

  yaml do
    map "eventDate", to: :event_date
  end
end
Example 2. Round-trip transformation demonstration
event = Event.new(event_date: Date.new(2024, 12, 25))

# XML serialization uses YYYYMMDD
xml = event.to_xml
# => <event><eventDate>20241225</eventDate></event>

# JSON serialization uses DDMMYYYY
json = event.to_json
# => {"eventDate":"25122024"}

# YAML serialization uses ISO8601
yaml = event.to_yaml
# => ---
# => eventDate: '2024-12-25'

# Round-trip: XML → Object → JSON
event_from_xml = Event.from_xml(xml)
event_from_xml.to_json
# => {"eventDate":"25122024"}

# Round-trip: JSON → Object → XML
event_from_json = Event.from_json(json)
event_from_json.to_xml
# => <event><eventDate>20241225</eventDate></event>

# All preserve the same date
event_from_xml.event_date == event_from_json.event_date
# => true

Approach 2: Attribute-Level Transform Procs

Overview

Attribute-level transform procs apply the same transformation logic across all serialization formats, defined directly on the attribute.

Structure

class MyModel < Lutaml::Model::Serializable
  attribute :attr_name, :type, transform: {
    export: ->(value) {
      # Transform model value before serialization
      # Applied to ALL formats (XML, JSON, YAML, etc.)
    },
    import: ->(value) {
      # Transform serialized value after deserialization
      # Applied to ALL formats (XML, JSON, YAML, etc.)
    }
  }
end

When to Use

Use Attribute-Level Transform Procs when:

  • ✅ Same transformation applies to ALL serialization formats

  • ✅ Logic is simple and inline

  • ✅ Specific to one attribute in one model

  • ✅ No format-specific behavior needed

  • ✅ Quick, non-reusable transformations

Transformation Flow

Attribute-level transformation flow
Deserialization (Import):
  Serialization Format Value (any format)
           ↓
  Attribute Transform: import
           ↓
  Model Attribute Value

Serialization (Export):
  Model Attribute Value
           ↓
  Attribute Transform: export
           ↓
  Serialization Format Value (any format)

Example: Uniform Date Format

class Document < Lutaml::Model::Serializable
  # Transform applies to ALL formats (XML, JSON, YAML)
  attribute :publication_date, :date, transform: {
    export: ->(date) {
      # ALL formats will get YYYYMMDD string
      date&.strftime("%Y%m%d")
    },
    import: ->(str) {
      # Parse YYYYMMDD from ALL formats
      return nil if str.nil? || str.empty?
      year = str[0..3].to_i
      month = str[4..5].to_i
      day = str[6..7].to_i
      Date.new(year, month, day)
    }
  }

  xml do
    root "document"
    map_element "pubDate", to: :publication_date
  end

  json do
    map "pubDate", to: :publication_date
  end
end
Example 3. Usage demonstration
doc = Document.new(publication_date: Date.new(2024, 12, 25))

# All formats use the same transformation
doc.to_xml
# => <document><pubDate>20241225</pubDate></document>

doc.to_json
# => {"pubDate":"20241225"}

doc.to_yaml
# => ---
# => pubDate: '20241225'

Approach 3: Mapping-Level Transform Procs

Overview

Mapping-level transform procs apply format-specific transformation logic, defined within each serialization format’s mapping block.

Structure

class MyModel < Lutaml::Model::Serializable
  attribute :attr_name, :type

  xml do
    map_element "name", to: :attr_name, transform: {
      export: ->(value) { xml_specific_export },
      import: ->(value) { xml_specific_import }
    }
  end

  json do
    map "name", to: :attr_name, transform: {
      export: ->(value) { json_specific_export },
      import: ->(value) { json_specific_import }
    }
  end
end

When to Use

Use Mapping-Level Transform Procs when:

  • ✅ Different transformation per serialization format

  • ✅ Format-specific requirements exist

  • ✅ One-off, non-reusable transformation

  • ✅ Quick inline modification needed

  • ✅ Combined with attribute-level transforms

Transformation Flow with Precedence

Complete transformation flow showing precedence
Deserialization (Import):
  Serialization Format Value
           ↓
  Mapping Transform: import (if defined for this format)
           ↓
  Attribute Transform: import (if defined)
           ↓
  Model Attribute Value

Serialization (Export):
  Model Attribute Value
           ↓
  Attribute Transform: export (if defined)
           ↓
  Mapping Transform: export (if defined for this format)
           ↓
  Serialization Format Value

Example: Format-Specific Date Representations

class Document < Lutaml::Model::Serializable
  attribute :publication_date, :date

  # XML wants YYYYMMDD
  xml do
    root "document"
    map_element "pubDate", to: :publication_date, transform: {
      export: ->(date) { date&.strftime("%Y%m%d") },
      import: ->(str) {
        return nil if str.nil? || str.empty?
        Date.new(str[0..3].to_i, str[4..5].to_i, str[6..7].to_i)
      }
    }
  end

  # JSON wants DDMMYYYY
  json do
    map "pubDate", to: :publication_date, transform: {
      export: ->(date) { date&.strftime("%d%m%Y") },
      import: ->(str) {
        return nil if str.nil? || str.empty?
        Date.new(str[4..7].to_i, str[2..3].to_i, str[0..1].to_i)
      }
    }
  end

  # YAML uses standard ISO8601 (no transform needed)
  yaml do
    map "pubDate", to: :publication_date
    # Date type's default serialization is ISO8601
  end
end
Example 4. Format-specific outputs
doc = Document.new(publication_date: Date.new(2024, 12, 25))

# Each format produces different string representation
doc.to_xml
# => <document><pubDate>20241225</pubDate></document>

doc.to_json
# => {"pubDate":"25122024"}

doc.to_yaml
# => ---
# => pubDate: '2024-12-25'

# But all parse back to the same Date object
Document.from_xml(doc.to_xml).publication_date
# => #<Date: 2024-12-25>

Document.from_json(doc.to_json).publication_date
# => #<Date: 2024-12-25>

Document.from_yaml(doc.to_yaml).publication_date
# => #<Date: 2024-12-25>

Decision Guide

Decision Matrix

The following matrix helps you choose the appropriate transformation approach:

Table 3. Value Transformation Decision Matrix
Scenario Best Approach Bidirectional? Format-Specific? Reusable?

Date: YYYYMMDD ↔ DDMMYYYY across formats

Custom Value Type

✓ Yes

✓ Yes

✓ Yes

Date: YYYYMMDD → YYYYWWDD (week calculation)

Custom Value Type

✓ Yes*

✓ Yes

✓ Yes

Always uppercase, all formats

Attribute Transform

✓ Yes

✗ No

✗ No

Prefix differs by format (JSON: "Dr.", XML: "Prof.")

Mapping Transform

✓ Yes

✓ Yes

✗ No

Temperature unit conversion

Custom Value Type

✓ Yes

Maybe

✓ Yes

API-specific field formatting

Mapping Transform

✓ Yes

✓ Yes

✗ No

Normalization (trim, lowercase)

Attribute Transform

✓ Yes

✗ No

✗ No

*Bidirectional if reverse calculation is possible (e.g., week date can be converted back to calendar date)

Comparison Table

Table 4. Detailed comparison of transformation approaches
Feature Custom Value Type Attribute Transform Mapping Transform

Scope

Reusable across models

Single attribute

Single attribute in one format

Format Support

Per-format methods (to_xml, from_json, etc.)

All formats uniformly

Per format individually

Complexity

Can be complex (full class definition)

Simple inline procs

Simple inline procs

Testability

Easy (unit testable)

Medium (integration tests)

Medium (integration tests)

Bidirectionality

Full bidirectional support

Bidirectional via import/export

Bidirectional via import/export

Type Safety

High (dedicated class)

Medium (proc validation)

Medium (proc validation)

Performance

Optimal (compiled methods)

Good (proc calls)

Good (proc calls)

Best For

Complex, reusable logic

Simple, uniform transforms

Format-specific transforms

Selection Flowchart

                    Need Value Transformation?
                              │
                              ▼
                    ┌───────────────────────┐
                    │ Will logic be reused  │
                    │ across models?        │
                    └───────────────────────┘
                        │              │
                   YES  │              │ NO
                        ▼              ▼
              ┌──────────────────┐  ┌──────────────────┐
              │ Format-specific  │  │ Same across all  │
              │ serialization    │  │ formats?         │
              │ needed?          │  └──────────────────┘
              └──────────────────┘      │         │
                  │         │      YES  │         │ NO
             YES  │         │ NO        ▼         ▼
                  ▼         ▼      ┌─────────┐ ┌────────────┐
        ┌──────────────┐ ┌──────┐ │Attribute│ │  Mapping   │
        │ Custom Value │ │Custom│ │Transform│ │ Transform  │
        │     Type     │ │Value │ │         │ │ (per fmt)  │
        │              │ │Type* │ │         │ │            │
        └──────────────┘ └──────┘ └─────────┘ └────────────┘
              ✓             ✓          ✓             ✓
        Bidirectional  Maybe just  Uniform     Format-
        format-aware   attribute   across all  specific
        reusable       transform   formats     one-off

*Custom Value Type can still be used even without format-specific needs
 for better encapsulation and reusability

Advanced Patterns

Combining Transformation Approaches

You can combine attribute-level and mapping-level transforms for sophisticated transformations.

Transformation Precedence

When both attribute and mapping transforms are defined, they are applied in sequence:

Deserialization Order: 1. Mapping transform (format-specific) 2. Attribute transform (format-independent)

Serialization Order: 1. Attribute transform (format-independent) 2. Mapping transform (format-specific)

Example 5. Combined transformation example
class Product < Lutaml::Model::Serializable
  # Attribute-level: Always capitalize
  attribute :name, :string, transform: {
    export: ->(value) { value.to_s.capitalize },
    import: ->(value) { value.to_s.downcase }
  }

  # Mapping-level: Add format-specific prefixes
  json do
    map "productName", to: :name, transform: {
      export: ->(value) { "JSON:#{value}" },
      import: ->(value) { value.gsub("JSON:", "") }
    }
  end

  xml do
    root "product"
    map_element "name", to: :name, transform: {
      export: ->(value) { "XML:#{value}" },
      import: ->(value) { value.gsub("XML:", "") }
    }
  end
end

product = Product.new(name: "laptop")

# Serialization flow:
# 1. Internal: "laptop"
# 2. Attribute export: "Laptop" (capitalize)
# 3. Mapping export: "JSON:Laptop" (add prefix)
product.to_json
# => {"productName":"JSON:Laptop"}

# 4. Mapping export: "XML:Laptop" (add prefix)
product.to_xml
# => <product><name>XML:Laptop</name></product>

# Deserialization flow for JSON:
# Input: {"productName":"JSON:LAPTOP"}
# 1. Mapping import: "LAPTOP" (remove "JSON:")
# 2. Attribute import: "laptop" (downcase)
Product.from_json('{"productName":"JSON:LAPTOP"}').name
# => "laptop"

Cross-Format Consistency

When designing transformations, consider how values should behave across different formats.

Strategy 1: Internal Canonical Form

Maintain a canonical internal representation, transform on serialization/deserialization.

# Internal: Always Date object
# External: Format-specific strings
class PortableDate < Lutaml::Model::Type::Date
  # Each format has its own representation
  def to_xml
    value&.strftime("%Y%m%d")  # YYYYMMDD
  end

  def to_json
    value&.strftime("%d/%m/%Y")  # DD/MM/YYYY
  end

  def to_yaml
    value&.iso8601  # ISO8601
  end
end

Strategy 2: Format Adaptation

Adapt to external format requirements while maintaining internal consistency.

class APIDate < Lutaml::Model::Serializable
  attribute :date, :date

  # External API expects ISO8601 in JSON
  json do
    map "apiDate", to: :date
    # Uses Date's default ISO8601 serialization
  end

  # Internal XML uses compact format
  xml do
    map_element "date", to: :date, transform: {
      export: ->(date) { date&.strftime("%Y%m%d") },
      import: ->(str) {
        Date.new(str[0..3].to_i, str[4..5].to_i, str[6..7].to_i)
      }
    }
  end
end

Complete Examples

Example 1: Bidirectional Date Format Conversion

This example demonstrates converting between YYYYMMDD and DDMMYYYY formats with full bidirectional support.

# Custom Value Type approach (RECOMMENDED for reusability)
class FlexibleDateFormat < Lutaml::Model::Type::Date
  # XML uses YYYYMMDD (Year-Month-Day)
  def self.from_xml(value)
    return nil if value.nil? || value.empty?

    year = value[0..3].to_i
    month = value[4..5].to_i
    day = value[6..7].to_i

    ::Date.new(year, month, day)
  rescue ArgumentError => e
    raise Lutaml::Model::InvalidValueError,
          "Invalid YYYYMMDD date format: #{value} (#{e.message})"
  end

  def to_xml
    value&.strftime("%Y%m%d")
  end

  # JSON uses DDMMYYYY (Day-Month-Year)
  def self.from_json(value)
    return nil if value.nil? || value.empty?

    day = value[0..1].to_i
    month = value[2..3].to_i
    year = value[4..7].to_i

    ::Date.new(year, month, day)
  rescue ArgumentError => e
    raise Lutaml::Model::InvalidValueError,
          "Invalid DDMMYYYY date format: #{value} (#{e.message})"
  end

  def to_json
    value&.strftime("%d%m%Y")
  end

  # YAML uses ISO8601 (standard)
  def self.from_yaml(value)
    ::Date.parse(value.to_s)
  end

  def to_yaml
    value&.iso8601
  end
end

# Usage in a model
class Event < Lutaml::Model::Serializable
  attribute :event_date, FlexibleDateFormat
  attribute :name, :string

  xml do
    root "event"
    map_element "eventDate", to: :event_date
    map_element "name", to: :name
  end

  json do
    map "eventDate", to: :event_date
    map "name", to: :name
  end

  yaml do
    map "eventDate", to: :event_date
    map "name", to: :name
  end
end
Example 6. Complete bidirectional demonstration
# Create event
event = Event.new(
  event_date: Date.new(2024, 12, 25),
  name: "Christmas"
)

# Serialize to different formats
xml = event.to_xml
# => <event><eventDate>20241225</eventDate><name>Christmas</name></event>

json = event.to_json
# => {"eventDate":"25122024","name":"Christmas"}

yaml = event.to_yaml
# => ---
# => eventDate: '2024-12-25'
# => name: Christmas

# Deserialize from each format
event_from_xml = Event.from_xml(xml)
event_from_xml.event_date
# => #<Date: 2024-12-25>

event_from_json = Event.from_json(json)
event_from_json.event_date
# => #<Date: 2024-12-25>

event_from_yaml = Event.from_yaml(yaml)
event_from_yaml.event_date
# => #<Date: 2024-12-25>

# Cross-format round-trip
Event.from_xml(event.to_xml).to_json
# => {"eventDate":"25122024","name":"Christmas"}

Event.from_json(event.to_json).to_xml
# => <event><eventDate>20241225</eventDate><name>Christmas</name></event>

# All maintain data integrity
event_from_xml == event_from_json
# => true

Example 2: Calculated Transformation (Week-Based Dates)

This example shows transforming calendar dates to ISO week dates, which requires calculation.

# Transforms between calendar dates and ISO week dates
class ISOWeekDate < Lutaml::Model::Type::Date
  # Parse standard YYYYMMDD calendar date
  def self.from_xml(value)
    return nil if value.nil? || value.empty?

    year = value[0..3].to_i
    month = value[4..5].to_i
    day = value[6..7].to_i

    ::Date.new(year, month, day)
  rescue ArgumentError => e
    raise Lutaml::Model::InvalidValueError,
          "Invalid calendar date: #{value} (#{e.message})"
  end

  # Serialize to YYYYWWDD format
  # YYYY: ISO year (may differ from calendar year near year boundaries)
  # WW: ISO week number (01-53)
  # DD: Day of week (1=Monday, 7=Sunday)
  def to_xml
    return nil unless value

    # ISO 8601 week date components
    year = value.cwyear  # Commercial (ISO) year
    week = value.cweek.to_s.rjust(2, '0')  # Week number with leading zero
    day = value.cwday  # Day of week (1-7)

    "#{year}#{week}#{day}"
  end

  # Parse YYYYWWDD week date format back to calendar date
  def self.from_json(value)
    return nil if value.nil? || value.empty?

    year = value[0..3].to_i
    week = value[4..5].to_i
    day = value[6].to_i

    # Date.commercial creates date from ISO week-date
    ::Date.commercial(year, week, day)
  rescue ArgumentError => e
    raise Lutaml::Model::InvalidValueError,
          "Invalid week date: #{value} (#{e.message})"
  end

  # Output same week format
  def to_json
    to_xml
  end
end

# Usage in a schedule model
class Schedule < Lutaml::Model::Serializable
  attribute :week_date, ISOWeekDate
  attribute :activity, :string

  xml do
    root "schedule"
    map_element "date", to: :week_date
    map_element "activity", to: :activity
  end

  json do
    map "weekDate", to: :week_date
    map "activity", to: :activity
  end
end
Example 7. Week date calculation demonstration
# Create schedule with calendar date
schedule = Schedule.new(
  week_date: Date.new(2024, 12, 25),  # Wednesday
  activity: "Team Meeting"
)

# Serializes to week format: YYYYWWDD
xml = schedule.to_xml
# => <schedule>
#      <date>20245203</date>
#      <activity>Team Meeting</activity>
#    </schedule>

# Parse the week date components:
# 2024: Year 2024
# 52: Week 52 of the year
# 03: Day 3 (Wednesday, where Monday=1)

# Deserialize and verify
parsed = Schedule.from_xml(xml)
parsed.week_date
# => #<Date: 2024-12-25>

# Round-trip maintains the date
parsed.week_date == schedule.week_date
# => true

# Edge case: Date near year boundary
new_year = Schedule.new(week_date: Date.new(2024, 12, 30))  # Monday
new_year.to_xml
# => <schedule>
#      <date>20240101</date>
#      <activity></activity>
#    </schedule>

# Note: 2024-12-30 is in ISO week 2024-W01
# (ISO year 2024, week 1, because it's the first Monday of 2025's first week)

Example 3: Using Transform Procs for Date Transformations

This example shows the transform proc approach for simpler scenarios.

# Mapping Transform approach (for format-specific, non-reusable)
class BlogPost < Lutaml::Model::Serializable
  attribute :published_on, :date
  attribute :title, :string

  # XML API requires YYYYMMDD
  xml do
    root "post"
    map_element "publishDate", to: :published_on, transform: {
      export: ->(date) { date&.strftime("%Y%m%d") },
      import: ->(str) {
        return nil if str.nil? || str.empty?
        Date.new(str[0..3].to_i, str[4..5].to_i, str[6..7].to_i)
      }
    }
    map_element "title", to: :title
  end

  # JSON API requires DD-MM-YYYY
  json do
    map "publishDate", to: :published_on, transform: {
      export: ->(date) { date&.strftime("%d-%m-%Y") },
      import: ->(str) {
        return nil if str.nil? || str.empty?
        parts = str.split("-")
        Date.new(parts[2].to_i, parts[1].to_i, parts[0].to_i)
      }
    }
    map "title", to: :title
  end

  # Internal YAML uses ISO8601 (no transform)
  yaml do
    map "publishDate", to: :published_on
    map "title", to: :title
  end
end
Example 8. Transform proc usage
post = BlogPost.new(
  published_on: Date.new(2024, 12, 25),
  title: "Holiday Special"
)

# Each format gets appropriate representation
post.to_xml
# => <post>
#      <publishDate>20241225</publishDate>
#      <title>Holiday Special</title>
#    </post>

post.to_json
# => {"publishDate":"25-12-2024","title":"Holiday Special"}

post.to_yaml
# => ---
# => publishDate: '2024-12-25'
# => title: Holiday Special

# All deserialize correctly
BlogPost.from_xml(post.to_xml).published_on
# => #<Date: 2024-12-25>

BlogPost.from_json(post.to_json).published_on
# => #<Date: 2024-12-25>

Best Practices

Design Principles

  1. Value Transformation Principle

    When a value needs to be transformed (not just mapped), use transformations. Mapping is for structural reorganization, transformation is for computational conversion.

  2. Bidirectionality Principle

    Always implement both import and export (or from_* and to_*) unless the transformation is genuinely one-way.

  3. Format Awareness Principle

    Consider whether different serialization formats need different representations. If yes, use Custom Value Types or Mapping Transforms.

  4. Reusability Principle

    If the logic will be used in multiple places, encapsulate it in a Custom Value Type class.

  5. Clarity Principle

    Choose the approach that makes the transformation logic most clear and maintainable.

Common Patterns

Pattern: Date Format Adapter

When working with external systems that require specific date formats:

# Create a custom type for the external system's format
class LegacySystemDate < Lutaml::Model::Type::Date
  # Legacy system uses MMDDYYYY
  def self.from_xml(value)
    return nil if value.nil? || value.empty?
    month = value[0..1].to_i
    day = value[2..3].to_i
    year = value[4..7].to_i
    ::Date.new(year, month, day)
  end

  def to_xml
    value&.strftime("%m%d%Y")
  end
end

Pattern: Calculated Derived Values

When values are calculated from dates:

class FiscalWeek < Lutaml::Model::Type::Integer
  # Assumes fiscal year starts April 1
  def self.from_xml(date_string)
    date = Date.parse(date_string)
    fiscal_year_start = Date.new(
      date.month >= 4 ? date.year : date.year - 1,
      4,
      1
    )
    ((date - fiscal_year_start) / 7).to_i + 1
  end

  def to_xml
    # Just output the week number
    value.to_s
  end
end

Pattern: Multi-Stage Transformation

Complex transformations can be broken into stages:

class ComplexDate < Lutaml::Model::Type::Date
  def self.from_xml(value)
    # Stage 1: Parse format
    parsed = parse_custom_format(value)
    # Stage 2: Validate
    validate_date_range!(parsed)
    # Stage 3: Adjust (e.g., timezone)
    adjust_timezone(parsed)
  end

  private

  def self.parse_custom_format(value)
    # Parsing logic
  end

  def self.validate_date_range!(date)
    # Validation logic
  end

  def self.adjust_timezone(date)
    # Timezone logic
  end
end

Performance Considerations

  1. Custom Value Types are more performant for reused logic (compiled methods vs repeated proc calls)

  2. Transform Procs are fine for simple, infrequent transformations

  3. Avoid excessive chaining of transforms; consolidate logic when possible

  4. Cache expensive calculations within custom type methods if needed

Migration Guide

From Transform Procs to Custom Value Types

If you find yourself repeating the same transform logic across multiple attributes:

Before (repetitive)
class Model1 < Lutaml::Model::Serializable
  attribute :date1, :date, transform: {
    export: ->(d) { d&.strftime("%Y%m%d") },
    import: ->(s) { Date.new(s[0..3].to_i, s[4..5].to_i, s[6..7].to_i) }
  }
end

class Model2 < Lutaml::Model::Serializable
  attribute :date2, :date, transform: {
    export: ->(d) { d&.strftime("%Y%m%d") },
    import: ->(s) { Date.new(s[0..3].to_i, s[4..5].to_i, s[6..7].to_i) }
  }
end
After (reusable)
class YYYYMMDDDate < Lutaml::Model::Type::Date
  def self.cast(value)
    case value
    when ::Date then value
    when ::String then from_xml(value)  # Reuse parsing logic
    else super
    end
  end

  def self.from_xml(value)
    return nil if value.nil? || value.empty?
    Date.new(value[0..3].to_i, value[4..5].to_i, value[6..7].to_i)
  end

  def to_xml
    value&.strftime("%Y%m%d")
  end

  # Apply to all formats
  alias_method :to_json, :to_xml
  alias_method :to_yaml, :to_xml

  class << self
    alias_method :from_json, :from_xml
    alias_method :from_yaml, :from_xml
  end
end

class Model1 < Lutaml::Model::Serializable
  attribute :date1, YYYYMMDDDate
end

class Model2 < Lutaml::Model::Serializable
  attribute :date2, YYYYMMDDDate
end

Summary

Quick Reference

Choose your transformation approach:

  1. Custom Value Type when:

    • Reusable logic

    • Format-specific needs

    • Bidirectional transformations

    • Complex parsing/calculation

  2. Attribute Transform when:

    • Uniform across all formats

    • Simple logic

    • Single attribute use

    • Quick inline transformation

  3. Mapping Transform when:

    • Format-specific requirements

    • One-off transformation

    • Combined with other approaches

    • API-specific formatting

Key Takeaways

  • Values cannot be mapped, they must be transformed

  • Models can be mapped, they don’t need transformation

  • Format conversion is bidirectional, calculated values may be unidirectional

  • Precedence matters when combining attribute and mapping transforms

  • Reusability drives the choice between Custom Types and Transform Procs