Contract ignores ConstraintError thrown by Dry::Types[Constructor]

Hey,
I’m having a problem with combining dry-validation with a custom type of dry-type defined with .constructor.

I’ve created an example with two types. One built with .constructor and the second with .constrained, both of them implements the same behaviour. Then I use those types with dry-validation expecting the same behaviour for each type.

It turns out when you use Dry::Types[Constructor] type with Dry::Validation::Contract, the contract won’t fail even though the type raises Dry::Types::ConstraintError. It looks like this happens because Dry::Validation provides it’s own fallback block when Dry::Types[Constructor] is used but does not do that with Dry::Types[Constrained].

It seems to be a bit inconsistent. Is it a desire bahaviour or am I missing something here?

GitHub Gist

require 'bundler/inline'

gemfile do
  source 'https://rubygems.org'
  gem 'dry-validation', '1.4.2'
  gem 'rspec', '3.9.0'
end

require 'dry-validation'

module Types
  include Dry::Types(default: :nominal)
  URL_REGEX = /\Awww\.(.*)\z/

  ConstrainedUrl = String.constrained(format: URL_REGEX)

  ConstructorUrl = String.constructor do |input, &block|
    if input.match?(URL_REGEX)
      input
    elsif block
      block.call
    else
      raise Dry::Types::ConstraintError.new("format?(#{URL_REGEX.inspect}, #{input.inspect})", input)
    end
  end
end

RSpec.describe 'Passing errors from dry-types to dry-validation', :aggregate_failures do
  let(:valid_url) { 'www.wp.pl' }
  let(:invalid_url) { 'wp.pl' }

  describe 'Types definitions' do
    shared_examples 'dry-type interface' do |type|
      describe "Type: #{type}" do
        context 'with invalid input' do
          let(:url) { invalid_url }

          it 'Throws Dry::Types::ConstraintError' do
            expect { type.call(url) }
              .to raise_error(Dry::Types::ConstraintError, "#{url.inspect} violates constraints (format?(/\\Awww\\.(.*)\\z/, #{url.inspect}) failed)")
          end

          context 'with block provided to the type' do
            let(:fallback) { :invalid }

            it 'returns fallback value' do
              expect(type.call(url) { fallback }).to eq(fallback)
            end
          end
        end

        context 'with valid input' do
          let(:url) { valid_url }

          it 'returns the input' do
            expect(type.call(url)).to eq(url)
          end

          context 'with a block provided to the type' do
            let(:fallback) { :invalid }

            it 'returns the input' do
              expect(type.call(url) { fallback }).to eq(url)
            end
          end
        end
      end
    end

    it_behaves_like 'dry-type interface', Types::ConstrainedUrl
    it_behaves_like 'dry-type interface', Types::ConstructorUrl
  end

  describe 'Validation::Contract definition' do
    shared_examples 'contract with typed input' do |type|
      describe "Type: #{type}" do
        subject(:result) { contract.call(url: url) }

        let(:contract) do
          Class.new(Dry::Validation::Contract) do
            params do
              required(:url).value(type)
            end
          end.new
        end

        context 'with invalid url' do
          let(:url) { invalid_url }

          it { is_expected.to_not be_success }

          it 'returns errors' do
            expect(result.errors.to_h).to eq(
              url: ['is in invalid format']
            )
          end
        end

        context 'with valid url' do
          let(:url) { valid_url }

          it { is_expected.to be_success }

          it 'returns no errors' do
            expect(result.errors).to be_empty
          end
        end
      end
    end

    it_behaves_like 'contract with typed input', Types::ConstrainedUrl
    it_behaves_like 'contract with typed input', Types::ConstructorUrl
  end
end

RSpec::Core::Runner.run(['spec', '--format', 'doc'])


# $ ruby dry_validation_spec.rb
# 
# Passing errors from dry-types to dry-validation
#   Types definitions
#     behaves like dry-type interface
#       Type: #<Dry::Types[Constrained<Nominal<String> rule=[format?(/\Awww\.(.*)\z/)]>]>
#         with invalid input
#           Throws Dry::Types::ConstraintError
#           with block provided to the type
#             returns fallback value
#         with valid input
#           returns the input
#           with a block provided to the type
#             returns the input
#     behaves like dry-type interface
#       Type: #<Dry::Types[Constructor<Nominal<String> fn=dry_validation_spec.rb:17>]>
#         with invalid input
#           Throws Dry::Types::ConstraintError
#           with block provided to the type
#             returns fallback value
#         with valid input
#           returns the input
#           with a block provided to the type
#             returns the input
#   Validation::Contract definition
#     behaves like contract with typed input
#       Type: #<Dry::Types[Constrained<Nominal<String> rule=[format?(/\Awww\.(.*)\z/)]>]>
#         with invalid url
#           is expected not to be success
#           returns errors
#         with valid url
#           is expected to be success
#           returns no errors
#     behaves like contract with typed input
#       Type: #<Dry::Types[Constructor<Nominal<String> fn=dry_validation_spec.rb:17>]>
#         with invalid url
#           is expected not to be success (FAILED - 1)
#           returns errors (FAILED - 2)
#         with valid url
#           is expected to be success
#           returns no errors
# 
# Failures:
# 
#   1) Passing errors from dry-types to dry-validation Validation::Contract definition behaves like contract with typed input Type: #<Dry::Types[Constructor<Nominal<String> fn=dry_validation_spec.rb:17>]> with invalid url is expected not to be success
#      Failure/Error: it { is_expected.to_not be_success }
#        expected `#<Dry::Validation::Result{:url=>"wp.pl"} errors={}>.success?` to return false, got true
#      Shared Example Group: "contract with typed input" called from dry_validation_spec.rb:112
#      # dry_validation_spec.rb:90:in `block (6 levels) in <main>'
#      # dry_validation_spec.rb:116:in `<main>'
# 
#   2) Passing errors from dry-types to dry-validation Validation::Contract definition behaves like contract with typed input Type: #<Dry::Types[Constructor<Nominal<String> fn=dry_validation_spec.rb:17>]> with invalid url returns errors
#      Failure/Error:
#        expect(result.errors.to_h).to eq(
#          url: ['is in invalid format']
#        )
#      
#        expected: {:url=>["is in invalid format"]}
#             got: {}
#      
#        (compared using ==)
#      
#        Diff:
#        @@ -1,2 +1 @@
#        -:url => ["is in invalid format"],
#        
#      Shared Example Group: "contract with typed input" called from dry_validation_spec.rb:112
#      # dry_validation_spec.rb:93:in `block (6 levels) in <main>'
#      # dry_validation_spec.rb:116:in `<main>'
# 
# Finished in 0.04198 seconds (files took 0.08128 seconds to load)
# 16 examples, 2 failures
# 
# Failed examples:
# 
# rspec 'dry_validation_spec.rb[1:2:2:1:1:1]' # Passing errors from dry-types to dry-validation Validation::Contract definition behaves like contract with typed input Type: #<Dry::Types[Constructor<Nominal<String> fn=dry_validation_spec.rb:17>]> with invalid url is expected not to be success
# rspec 'dry_validation_spec.rb[1:2:2:1:1:2]' # Passing errors from dry-types to dry-validation Validation::Contract definition behaves like contract with typed input Type: #<Dry::Types[Constructor<Nominal<String> fn=dry_validation_spec.rb:17>]> with invalid url returns errors

Using custom types in contracts is tricky business, I wouldn’t recommend it. It is not possible to issue user-friendly errors from types, this subject was discussed on the forum a few times already. Constructor types can never “fail” in the context of dry-schema, this is by design. This is achieved by turning all types to lax (just call .lax on any type). Practically, this means the output of a constructor type will always be run against predicates no matter if it’s valid from the POV of the constructor. In your case, block is called without an argument meaning the output of the constructor is equal to its input which then passed to predicates inferred by dry-schema from the type. Since it can only infer :str? from that type it’s considered valid. If you change block.call to block.call(nil) you’ll see a different result: :url => ["must be a string"]. Constructor types are OK for trimming/turning empty strings etc, not for running checks.

1 Like