Suggestion: Simplify Dry::Structs type system

I’ve been trying to get my colleagues to switch over from plain classes to dry struct for a while and the biggest obstacle for most people seems to be its dependence on Dry::Types. Abstract data types aren’t very common in Ruby and RBS introduced in Ruby 3.0 is still very new to most developers. There also seems to be a lot of confusion in regards to its syntax, i.e Types::Array vs Types.Array(a type). To a beginner, Array() looks like a module that now takes a type. Even when I try to explain the difference, the next question is often: ff it is a method, why is it capitalized?

I would suggest a type system built on top of Dry::Types that allows the developer to define type constraints using native Ruby classes

A few examples would be

  • String == Types::String.
  • [String] == Types.Array(Types::String).
  • String, Symbol == Types::String | Types::Symbol.
  • [String, Symbol] == Types.Array(Types::String | Types::Symbol).
  • MyClass == Types.Constructor(MyClass, &MyClass.method(:new)).
  • { Symbol => [String] } == Types::Hash.map(Types::Symbol, Types.Array(Types::String))

Dry initializer does this to some extent when wrapping array types

option :emails, [[proc(&:to_s)]]

This could fairly easily be accomplished by using refinements to recursively build the type (see extension.rb below) without affecting the existing behavior

module Extension
  module Type
    module Types
      include Dry::Types()
    end

    refine String do
      # Converts dry type references into a type
      #
      # @example A strict string into a type
      #   type = "strict.string".to_type
      #
      #   type.valid?("string") # => true
      #   type.valid?(:symbol)  # => false
      #
      # @example A (strict) symbol into a type
      #   type = "symbol".to_type
      #
      #   type.valid?(:symbol)  # => true
      #   type.valid?("string") # => false
      #
      # @return [Dry::Types::Type]
      # @raise [ArgumentError] if the type is not a valid type
      def to_type
        Dry::Types[self]
      rescue Dry::Container::Error
        raise ArgumentError, "Type reference [#{inspect}] not found in Dry::Types"
      end
    end

    refine Object do
      def to_type
        raise ArgumentError, "[#{self}] cannot be converted into a type"
      end
    end

    refine Dry::Types::Type do
      # Dry::Types::Type is already a type in itself
      # Used to streamline the API for all objects
      #
      # @example Dry type to dry type
      #   type = Dry::Types['string'].to_type
      #
      #   type.valid?("string") # => true
      #   type.valid?(:string)  # => false
      #
      # @return [Dry::Types::Type]
      alias_method :to_type, :itself
    end

    refine Module do
      # Ensures passed value includes module
      #
      # @example Check for enumerable values
      #   type = Enumerable.to_type
      #
      #   type.valid?([])  # => true
      #   type.valid?({})  # => true
      #   type.valid?(nil) # => false
      #
      # @return [Dry::Types::Constrained]
      def to_type
        Types::Any.constrained(type: self)
      end
    end

    refine Class do
      # Wrapps class in a type constructor using ::new as initializer
      #
      # @example With a custom class
      #   type = Struct.new(:value).to_type
      #
      #   type.valid?('value')            # => true
      #   type.valid?                     # => false
      #
      # @example With a native Ruby class
      #   type = String.to_type
      #
      #   type.valid?('value')            # => true
      #   type.valid?(:symbol)            # => false
      #
      # @example With an instance of the class
      #   Person = Struct.new(:name)
      #
      #   type = Person.to_type
      #
      #   type.valid?('value')             # => true
      #   type.valid?(Person.new('John'))  # => true
      #   type.valid?                      # => false
      #
      # @example With a class without constructor args
      #   type = Mutex.to_type
      #
      #   type.valid?                      # => true
      #   type.valid?('value')             # => false
      #
      # @return [Dry::Types::Constructor]
      def to_type
        Types.const_get(name).then do |maybe_type|
          maybe_type == self ? to_constructor : maybe_type
        end
      rescue NameError, TypeError
        to_constructor
      end

      private

      def to_constructor
        Types.Instance(self) | Types.Constructor(self, method(:new))
      end
    end

    refine Hash do
      # Recursively creates a hash type from {keys} & {values}
      #
      # @example With dynamic key and static value
      #   type = { String => 'value' }
      #
      #   type.valid?('string' => 'value') # => true
      #   type.valid?(symbol: 'value')     # => false
      #   type.valid?('string' => 'other') # => false
      #
      # @example With dynamic key and value
      #   type = { String => Enumerable }.to_type
      #
      #   type.valid?('string' => [])      # => true
      #   type.valid?(symbol: [])          # => false
      #   type.valid?('string' => :symbol) # => false
      #
      # @return [Dry::Types::Constrained, Dry::Types::Map]
      def to_type
        return Types::Hash if empty?

        map { |k, v| Types::Hash.map(k.to_type, v.to_type) }.reduce(:|)
      end
    end

    refine Array do
      # Recursively creates an array type from {self}
      #
      # @example With member type
      #   type = [String].to_type
      #
      #   type.valid?(['string'])            # => true
      #   type.valid?([:symbol])             # => false
      #
      # @example Without member type
      #   type = [].to_type
      #
      #   type.valid?(['anything'])          # => true
      #   type.valid?('not-an-array')        # => false
      #
      # @example With nested members
      #   type = [[String]].to_type
      #
      #   type.valid?([['string']])          # => true
      #   type.valid?([[:symbol]])           # => false
      #   type.valid?(['string'])            # => false
      #
      # @example With combined types
      #   type = [String, Symbol].to_type
      #
      #   type.valid?(['string', :symbol])   # => true
      #   type.valid?(['string'])            # => true
      #   type.valid?([:symbol])             # => true
      #   type.valid?([])                    # => true
      #   type.valid?(:symbol)               # => false
      #
      # @return [Dry::Types::Constrained]
      def to_type
        return Types::Array if empty?

        Types.Array(map(&:to_type).reduce(:|))
      end
    end
  end
end
class Example < Dry::Struct
  attribute :field, String
  attribute :field, Float, Integer
  attribute :field, Value('id')
  attribute :field, String, default: 'John Doe'
  attribute :field, String, min_size: 3
  attribute :field, Coercible::String
  attribute :field,  Enumerable
  attribute :field,  OpenStruct
  attribute :field, [String]
  attribute :field, [String, Symbol]
  attribute :field, [Value(:id), Value(:null)]
  attribute :field, { String => Hash }
  attribute :field, { String => Value('value') }
  attribute :field, { String =>  [Value(:id), Value(:null)] }
  attribute :field, { [String] => String }
  attribute :field, { [[Coercible::Symbol], String, Types::Symbol] => [OpenStruct, :id] }

What do you think, could this improve the user experience?