Custom types and working around changes in dry-schema

I’ve managed to create my own types, use my own registry, and get custom types mostly working with a schema. It took a bit of a code dive, but… it’s all good.

One of the issues I have in making a custom type work like a built-in type is when handling errors from the constructor.

Let’s look at one of the built-ins:

module Dry
  module Types
    module Coercions
      module JSON
        def self.to_decimal(input, &block)
          if input.is_a?(::Float)
            input.to_d
          else
            BigDecimal(input)
          end
        rescue ArgumentError, TypeError
          if block_given?
            yield
          else
            raise CoercionError, "#{input} cannot be coerced to decimal"
          end
        end
      end
    end
  end
end

In looking at my own implementation and the built-ins, I see that all the built-ins coercion methods yield to a block if there is a coercion failure or raise a Dry::Types::CoercionError. Okay.

So if I try something like this: Dry::Types['json.decimal']['abc'] I get: Dry::Types::CoercionError (abc cannot be coerced to decimal)

Okay, cool. So then if I make a schema:

schema = Dry::Schema.JSON do
  optional(:num).filled(:decimal)
end

schema.(num: 'abc') => #<Dry::Schema::Result{:num=>"abc"} errors={:num=>["must be a decimal"]}>

The block is passing in the error.

So then, I make a custom type:

Line = Types::String.constructor do |input, &block|
  input.to_s.strip.tap do |str|
    if ["\n", "\r"].any? { |c| input.include?(c) }
      return yield if block_given?
      raise Dry::Types::CoercionError, "Must be a single line."
    end
  end
end

schema = Dry::Schema.JSON do
  optional(:name).filled(Line)
end

schema.(name: "Hello\nWorld") => Dry::Types::CoercionError (Must be a single line.)

block_given? is false when I jump in there with a debugger. What do I have to do in order for the schema to pass a block with an error and not raise the exception? I feel like I’m missing generating an error message, but I figured I’d ask around here before I went any further down the rabbit hole on my own.

I know I can accomplish a lot of this stuff with macros with Dry-Validation. However, if I want to:

  1. Produce a custom Type that isn’t just a string (like a UUID object) this is problematic and I can’t get the behavior consistent with the built-in types.
  2. For the sake of convenience, the example above, would be such a common pattern that just setting the schema without having to write rules every single time I want to accomplish this is a lot easier on the eyes and fingers.
1 Like

First of all, custom messages for custom types are not supported. Types are not represented as objects in dry-schema, only as a bunch of predicates, so to speak.
Your type constructor is incorrect for a few reasons but they related to understanding how scoping works in Ruby:

  1. You cannot use return within a block/proc (one exception is define_method, return works the same way it does in lambda). If block_given? returns true it’ll blow up with a LocalJumpError.
  2. block_given? returns false because it looks for a block within the self-scope:
    module Types
      block_given? # => false
    end
    

See how JSON.to_decimal is different: it’s a module method. You can fix you code by check for block with block.() if block and replacing return with a condition. This won’t give you a custom message, though.
Instead, I would recommend writing a macro in dry-validation, they are designed for this purpose.

Yeah, I see those issues. It was originally in some conditional logic and I was getting tired after like the 12th go-round. Thanks for pointing out my bonehead mistake.

The greater issue is this:

First of all, custom messages for custom types are not supported.

I guess what I am trying to figure out is—and it’s in line with the fact that custom predicates are not supported in Dry-Schema—why not?

Is it something that there is intent to support in the future or is it something that from a design/philosophical standpoint that there are no plans to introduce? (And just to be clear, I’m seriously just asking. This isn’t, “Hey, please implement this for me because I want it!”)

I made heavy use of custom predicates in 0.11.x of Dry-Validations and while it can be accomplished via macros, I’m lost as to what goes where (or really, why something goes where). For instance, why is something like :format? or :gteq? a predicate rather than something in a macro in a validation?

@solnic privately mentioned it is possible to use custom predicates, but there’s no simple API for this. I didn’t try it. The idea is separation type checks from domain-level checks. Yes, some predicates from dry-logic are available out of the box, but this is it. Instead of embedding (possibly) complex logic within type lamdas dry-validation offers rule blocks, macros and the :predicates_as_macros extension which registers dry-logic’s predicates as macros.
Overall, yes, this is our current standpoint and our current vision. So far, no one showed us convincing arguments for adding convenient API for custom predicates. However, this may change in the future.

The issue with producing custom messages in dry-schema could be solved, but we need to come up with a nice API for it, otherwise, it’s not worth the effort.

1 Like

The idea is separation type checks from domain-level checks.

Type checks can also sometimes benefit from more meaningful messages. For example, I am trying to parse a HTTP GET parameter from string into a Date. I want to support YYYY-MM-DD format and nothing else, so a naive implementation would do something like Date.strptime(value, '%Y-%m-%d').

Following the doc, I have come up with something like this:

my_schema = Dry::Schema.Params do
  date_from_string = Dry::Types['date'].constructor do |input|
    Date.strptime(input, '%Y-%m-%d')
  end
  optional(:my_date).filter(:str?, :filled?).maybe(date_from_string)
end

And the results are as follows:

my_schema.call({my_date: '2020-10-12'})
# => #<Dry::Schema::Result{:my_date=>Mon, 12 Oct 2020} errors={}>

my_schema.call({my_date: nil})
# => #<Dry::Schema::Result{:my_date=>nil} errors={:my_date=>["must be a string"]}>
my_schema.call({my_date: ''})
# => #<Dry::Schema::Result{:my_date=>""} errors={:my_date=>["must be filled"]}>
my_schema.call({my_date: '2020/10/11'})
=> #<Dry::Schema::Result{:my_date=>"2020/10/11"} errors={:my_date=>["must be a date"]}>

Note how in the last example the string represents a date in some locales, but in an ambiguous manner, i.e. in the US it would mean Nov 10th and in Italy it would mean Oct 11th. Our API refuses to guess, and wants the dates only in the ISO format, but the users from US or Italy may be confused: why is 2020/10/11 not a date when it obviously is a date?

Ideally, the API would return an error saying “date must be in YYYY-MM-DD format”, but I see no way to do that with “just a predicate” approach. I could add a format-based validator (smth like /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/) as a filter, but it would still return a super generic is in invalid format message.

How would you suggest getting the desired output using dry-schema?

You can simply use filter(:str?, format?: /your-format-regex/) and customize the default message for the date key in your errors.yml.