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
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
endAdding 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