dry-types
dry-types copied to clipboard
Unexpected error raised from Sum type
Describe the bug
👋 Hey guys, I've been playing around dry-types and found an error raised from sum types unexpected.
To Reproduce
require 'dry-types' # => 1.5.1
require 'dry-struct' # => 1.4.0
require 'dry/logic' # => 1.2.0
require 'BigDecimal'
module Types
include Dry.Types()
include Dry::Logic
end
class FixedAmount < Dry::Struct
attribute :type, Types.Value("fixed")
attribute :value, Types::Coercible::Decimal.constrained(gteq: BigDecimal(0))
end
class Percentage < Dry::Struct
attribute :type, Types.Value("percentage")
attribute :value, Types::Coercible::Decimal.constrained(gteq: BigDecimal(0), lteq: BigDecimal(100))
end
Value = FixedAmount | Percentage
class DiscountSchema < Dry::Struct
attribute :value, Value
end
# Success
p DiscountSchema.new(value: { type: "fixed", value: "1.1" })
# => #<DiscountSchema value=#<FixedAmount type="fixed" value=0.11e1>>
p DiscountSchema.new(value: { type: "fixed", value: "1.1", applies_to_each_item: true })
# => <DiscountSchema value=#<FixedAmount type="fixed" value=0.11e1>
p DiscountSchema.new(value: { type: "percentage", value: "10" })
# => #<DiscountSchema value=#<Percentage type="percentage" value=0.1e2>>
# Unexpected
p DiscountSchema.new(value: { type: "fixed", value: -1.1 })
# => [Percentage.new] "fixed" (String) has invalid type for :type violates constraints (eql?("percentage", "fixed") failed) (Dry::Struct::Error)
Expected behavior
I'm expecting the error raised for FixedAmount
, which satisfies the type checking on :type
but fails the constraint on :value
. I guess the sum type would try applying both constructors and return last failure as the error.
My environment
- Affects my production application: NO
- Ruby version: 2.6.8p205
- OS: macOS 12.3
Thanks for this detailed report, this is clearly a bug in error handling logic in Sum
type although I can't tell why it's happening after having a quick look at Sum#try 😕
Sum returns the last error, this was always the case. It tries the left type first, then thee right one. It also bothers me but I'm not sure this can be improved. We could probably collect all failures but this will affect performance. Also, message size can be really large for complex types.
FWIW I don't think it's a bug.
Oh boy, now I remember it's not the first time when I'm getting confused by this :( Maybe we should improve the error message? Like "input violates constraints of all the possible types, last error: blah blah"?
Yeah, it would definitely make sense.
Hi, all! First of all, just want to thank you guys for the amazing library ❤️
I've faced the same issue that a sum type raises only the error of the last type's constructor, which is really confusing.
After looking into the implementation details I think the problem with returning an error that gives a more comprehensive message is that it needs to somehow pass some state from all the left types to the right ones step by step because a final sum type it's only a sum of a complex left and a simple right. Is that right?
What do you think about adding a union type that is going to store all the types at the same level?
Here is what I mean
class Union < Dry::Types::Union
possible_types(
Dry::Types::String,
Dry::Types::Integer,
# and etc
)
end
One additional benefit that we could get from adding a union type is explicit control over what type an input should be assigned.
Here is an example:
class A < Dry::Struct
attribute :value, Types::String
end
class B < Dry::Struct
attribute :value, Types::String
end
Data = A | B
hash = { value: 'a' }
p Data[hash].class # => It is always going to be the first left type in a sum.
With a union type, it would be possible to override the default behavior, which is finding a first type whose constructor succeeds.
class Data < Dry::Types::Union
possible_types A, B
class << self
def resolve(data)
case data
in { value: 'a' }
A
in { value: 'b' }
B
end
end
end
end
Here is a draft I wrote and which worked out in my case:
require 'bundler/inline'
gemfile do
source 'https://rubygems.org'
gem 'dry-types'
gem 'dry-struct'
end
module Types
include Dry.Types()
end
module Dry
module Types
class Union
include Builder
include Meta
attr_reader :types
Error = Class.new(StandardError)
def initialize(*types)
raise ArgumentError, 'a union should consist of at least 2 types' if types.size < 2
@types = types
@meta = {}
end
def try(input)
type = resolve(input)
return Dry::Types::Result::Success.new(input) if type
error = Error.new("A value does not match any type of union")
failure = Dry::Types::Result::Failure.new(input, error)
yield failure if block_given?
failure
end
def [](input)
call_unsafe(input)
end
def call_unsafe(input)
type = resolve(input)
raise Error, "value #{input} does not match any type of union" if type.nil?
type.call_unsafe(input)
end
def constrained?
false
end
private
def resolve(input)
find_type(input)
end
def find_type(input)
types.find { |type| type.valid?(input) }
end
class << self
def of(*types, &block)
new(*types).tap do |instance|
instance.instance_exec(&block) if block_given?
end
end
end
end
end
end
class A < Dry::Struct
attribute :value, Types::String
end
class B < Dry::Struct
attribute :value, Types::String
end
Data = Dry::Types::Union.of(A, B) do
def resolve(input)
case input
in { value: 'a' } if A.valid?(input)
A
in { value: 'b' } if B.valid?(input)
B
else
find_type(input)
end
end
end
p Data[{ value: 'a' }].class
p Data[{ value: 'b' }].class
p Data[{ }] # => Error
I haven't really dived into all the details of what interfaces the type should implement so my point here is to just make a suggestion.
I would really appreciate hearing your opinions on that @solnic @flash-gordon