Suggestion: Custom & fallback constructors structs

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 Cars 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 Cars 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 Cars 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"))