Make included_in constrain dynamic

Hello everyone,

I’m currently using dry-types version 1.7.2 in a large project, and I need to update a custom type to work with a dynamic list for the included_in constraint without breaking existing tests.

Previously, I had:

Currency = String.constrained(
  included_in: Money::Currency.all.map(&:iso_code)
)

I changed it to:

Currency = Types::String.constructor do |value|
  iso_codes = Money::Currency.all.map(&:iso_code)

  if iso_codes.include?(value)
    value
  else
    list = iso_codes.map { |iso_code| %["#{iso_code}"] }.join(', ')
    err_message = %["#{value}" violates constraints (included_in?([#{list}], "#{value}") failed)]
    raise Dry::Types::ConstraintError.new(value, err_message)
  end
end

Could someone please help me update the Currency constructor so that it behaves exactly like included_in, ensuring that all tests in the project continue to pass?

I think I’m raising the error the wrong way

Am I correct in assuming that you are attempting to late-bind this list, so that Money::Currency.all can be populated at startup?

If so, you’re overthinking it:

Currency = Types::String.constructor do |value, type|
  type.constrained(included_in: Money::Currency.all.map(&:iso_code)).call(value)
end
1 Like

At validation moment to be correct

But anyway, the code you provided works perfectly! You’re awesome, thank you a lot!

@alassek one more thing - is there a way to make the behavior exactly the same?

module NewTypes
  include Dry.Types()

  CURRENCIES = %w[EUR USD]

  Currency1 = String.constrained(
    included_in: CURRENCIES
  )

  Currency2 = String.constructor do |value, type|
    type.constrained(included_in: CURRENCIES).call(value)
  end
end

CurrencyParams1 = Dry::Schema.Params do
  required(:currency).filled(::NewTypes::Currency1)
end

CurrencyParams2 = Dry::Schema.Params do
  required(:currency).filled(::NewTypes::Currency2)
end

I want to keep this behaviour (to keep tests in project continue to pass):

[3] pry(main)> CurrencyParams1.call(currency: 'INVALID')

=> #<Dry::Schema::Result{:currency=>"INVALID"} errors={:currency=>["must be one of: EUR, USD"]} path=[]>

But I’m getting this:

[4] pry(main)> CurrencyParams2.call(currency: 'INVALID')

Dry::Types::ConstraintError: "INVALID" violates constraints (included_in?(["EUR", "USD"], "INVALID") failed)
from /bundle/ruby/3.2.0/gems/dry-types-1.7.2/lib/dry/types/constrained.rb:37:in `call_unsafe'

It does not appear that Dry::Schema will work with that approach.

I’m going to show you the correct way to do this, and then the quick-and-dirty way.

Types are not meant to be mutable at runtime. If you need runtime data to alter the behavior of your validations, then a type object is the wrong level of abstraction for this.

The correct place to do this in a dry-validator contract. It is specifically built to allow for runtime dependencies.

class MoneyField < Dry::Validation::Contract
  option :iso_codes, Types::Array.of('string')

  params do
    required(:currency).filled
  end

  rule :currency do
    unless iso_codes.include?(value)
      key.failure("must be one of: #{iso_codes.join(', ')}")
    end
  end
end

contract = MoneyField.new(
  iso_codes: Money::Currency.all.map(&:iso_code)
)

contract.call(currency: "EUR")

There is a bad way to do this. The object passed to :included_in is stored by reference, and can be mutable.

module AllowedCurrencies
  ISO_CODES = []

  def self.setup(codes)
    ISO_CODES.clear.concat(codes)
  end
end

Types = Dry.Types(default: :strict)

module Types
  Currency = String.constrained(included_in: AllowedCurrencies::ISO_CODES)
end

# Somewhere in your startup routine
Money::Currency.all.map(&:iso_code).then do |codes|
  AllowedCurrencies.setup(codes)
end

Since this approach relies on globally-mutable state, I would advise against this.

This is a situation where dry-auto_inject shines.

module Container
  extend Dry::Core::Container::Mixin

  register "currency.iso_codes" do
    Money::Currency.all.map(&:iso_code)
  end
end

Deps = Dry::AutoInject(Container)

class MoneyField < Dry::Validation::Contract
  include Deps["currency.iso_codes"]

  params do
    required(:currency).filled
  end

  rule :currency do
    unless iso_codes.include?(value)
      key.failure("must be one of: #{iso_codes.join(', ')}")
    end
  end
end

MoneyField.new.call(currency: "USD")

@alassek Thank you, man! How can I tip you to show my appreciation?