Introduction

Custom types let you create reusable value types with special behavior, validation, and format-specific serialization. This tutorial shows when and how to create them.

Type vs model: When to use each

Use a custom type when

  • You need a single primitive-like value with custom behavior

  • The type will be reused across multiple attributes

  • Format-specific serialization is needed

  • Examples: Currency, PhoneNumber, PostalCode, Email

Use a model when

  • You need multiple related attributes

  • Complex nested structures are required

  • Examples: Address, ContactInfo, Configuration

Creating a simple custom type

Custom types inherit from Lutaml::Model::Type::Value or built-in types like Type::String.

Example 1. Creating a PostalCode type
class PostalCode < Lutaml::Model::Type::String
  def self.cast(value)
    # Normalize: remove spaces, uppercase
    value.to_s.upcase.gsub(/\s/, '')
  end
end

class Address < Lutaml::Model::Serializable
  attribute :street, :string
  attribute :city, :string
  attribute :postal_code, PostalCode

  key_value do
    map 'street', to: :street
    map 'city', to: :city
    map 'postalCode', to: :postal_code
  end
end

address = Address.new(
  street: "123 Main St",
  city: "Springfield",
  postal_code: "ab12 3cd"  # Will be normalized
)

puts address.postal_code
# => "AB123CD"

Required methods

Every custom type must implement:

self.cast(value)

Converts input to internal representation

self.serialize(value)

Converts internal value for output

Example 2. Complete custom type example
class Currency < Lutaml::Model::Type::Value
  def self.cast(value)
    case value
    when String
      # Remove currency symbols
      Float(value.gsub(/[$,]/, ''))
    when Numeric
      value.to_f
    else
      raise Lutaml::Model::TypeError, "Invalid currency: #{value}"
    end
  end

  def self.serialize(value)
    sprintf("%.2f", value)
  end
end

class Product < Lutaml::Model::Serializable
  attribute :name, :string
  attribute :price, Currency

  json do
    map 'name', to: :name
    map 'price', to: :price
  end
end

product = Product.new(name: "Vase", price: "$99.99")
puts product.price
# => 99.99

puts product.to_json
# => {"name":"Vase","price":"99.99"}

Format-specific serialization

Override format-specific methods for different representations:

Example 3. Custom type with format-specific output
class Currency < Lutaml::Model::Type::Value
  def self.cast(value)
    case value
    when String then Float(value.gsub(/[^0-9.-]/, ''))
    when Numeric then value.to_f
    end
  end

  def self.serialize(value)
    sprintf("%.2f", value)
  end

  # XML uses currency symbol
  def to_xml
    "$#{sprintf('%.2f', value)}"
  end

  # JSON uses plain number
  def to_json(*_args)
    value
  end
end

product = Product.new(name: "Vase", price: 99.99)

puts product.to_xml
# => <product><name>Vase</name><price>$99.99</price></product>

puts product.to_json
# => {"name":"Vase","price":99.99}

Registering custom types

Register types for reuse with symbols:

# Register the type
Lutaml::Model::Type.register(:currency, Currency)
Lutaml::Model::Type.register(:postal_code, PostalCode)

# Now use symbols instead of class names
class Product < Lutaml::Model::Serializable
  attribute :price, :currency
  attribute :ship_to, :postal_code
end

Adding validation

Use cast to validate input:

Example 4. Type with validation
class Email < Lutaml::Model::Type::String
  EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i

  def self.cast(value)
    email = super(value)  # Use String's cast first

    unless email.match?(EMAIL_REGEX)
      raise Lutaml::Model::TypeError, "Invalid email: #{email}"
    end

    email.downcase  # Normalize to lowercase
  end
end

class Contact < Lutaml::Model::Serializable
  attribute :email, Email
end

# Valid email
contact = Contact.new(email: "John@Example.COM")
contact.email
# => "john@example.com"

# Invalid email
bad_contact = Contact.new(email: "not-an-email")
# => Lutaml::Model::TypeError: Invalid email: not-an-email

Handling special values

The cast method may receive special sentinel values:

  • nil - Explicitly null value

  • UninitializedClass.instance - No value provided, no default set

These are handled automatically at the base Type::Value level via the UninitializedClassGuard module. Your custom type’s cast method will only be called with actual values, not these sentinels.

If you call super in your cast method, the sentinel values are handled automatically. You only need explicit handling if you override cast completely without calling super.

Example: Custom type that calls super
class Email < Lutaml::Model::Type::String
  EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i

  def self.cast(value)
    email = super(value)  # Sentinel values handled by UninitializedClassGuard

    return email if Lutaml::Model::Utils.uninitialized?(email)
    return nil if email.nil?

    unless email.match?(EMAIL_REGEX)
      raise Lutaml::Model::TypeError, "Invalid email: #{email}"
    end

    email.downcase
  end
end