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?