Allow structs to define a fallback value whenever the default constructor fails.
Image this struct representing a person with a car.
class Person < Dry::Struct
attribute :car, Types::String
end
Person.call(car: "Volvo")
Now image we want to move the value ”Volvo”
into its own class to perform some kind of complex operation.
class Car < Dry::Struct
attribute :model, Types::String
end
class Person < Dry::Struct
attribute :car, Car
end
This new data structure requires us to change the input structure.
Person.call(car: { model: “Volvo” })
This isn’t always possible as it requires a transformation layer between the input and constructor.
What I’ve done in the past is to override Car
s constructor
class Person < Dry::Struct
attribute :car, Car.constructor { |input, type|
type[input] { type[model: input] }
}
end
This works but breaks the principle of least knowledge as Person
now is required to know about the internals of Car
.
The second solution is to override Car
s constructor
class Car < Dry::Struct
attribute :model, "string"
def self.new(input, *args, &block)
super
rescue Error
super({model: input}, *args, &block)
end
end
Better, but now instead we have to deal with the internals of Dry::Struct
, namely args
which contains safe
and block
. This is also leaky as safe
isn’t part of the public API nor is it obvious that the input now contains the extra parameter. Its also doesn’t take keyword arguments into consideration which adds a layer of confusion
What if we added a fallback constructor to Dry::Struct
that allows us to restructure the input whenever the default constructor fails
class Dry::Struct
def self.fallback(&block)
schema schema.fallback(&block)
end
end
class Car < Dry::Struct
attribute :model, "string"
fallback do |input|
call(model: input)
end
end
This solution encapsulates Car
s behavior and doesn’t rely on the internals of Dry::Struct
. This doesn’t actually work as a failure in fallback
causes infinitive recursion. An alternative approach would be to add support for custom constructors
class Dry::Struct
# as constructor is already taken
def self.initializer(&block)
schema schema.constructor(&block)
end
end
class Car < Dry::Struct
attribute :model, "string"
initializer do |input, type|
type[input] { type[model: input] }
end
end
We can use initializer
to define fallback
class Dry::Struct
def self.fallback(&block)
initializer do |input, type|
type.fallback do
block[input, type]
end.call(input)
end
end
end
class Car < Dry::Struct
fallback do |input, type|
type[model: input]
end
end
Test input
Person.new(car: "Volvo")
Person.new(car: { model: "Volvo" })
Person.new(car: Car.new("Volvo"))
Person.new(car: Car.new(model: "Volvo"))