Hello, I recently used some of the dry-rb gems in a small Sinatra app for form input validation and JSON Hash coercion for Sidekiq workers. Here is some feedback based on my experience setting up the gems and navigating the guides. I need to stress to maintainers and fans of dry-rb, I am not attacking dry-rb, just pointing out things that stuck out to me, a beginner who’s unfamiliar with the dry-rb way of doing things.
Dot-style calling vs. ::-style calling
Immediately I noticed the use of top-level Capitalized Methods, such as Dry::Schema.Params do ... and Dry::Types().
However, most Ruby code and Rubyists do not call Capitalized class methods such as Foo.Bar(...), but instead do Foo::Bar(...). However, Ruby’s syntax doesn’t allow Foo::Bar do, so you must do Foo::Bar() do. I found the mixing of dot-style method calling with Capitalized Methods kind of surprising/confusing at first, and wondered why those methods couldn’t have been given lower-case names or possibly aliases? Perhaps changing the examples to use ::-style method calls for the top-level Capitalized Methods would make the examples less surprising for beginners.
include Dry::Types()
While include Dry::Types() is starting to grow on me, it was still unusual to see this style of code. After looking at the API documentation for Dry::Types() it accepts additional arguments and calls Dry::Types::Module.new(...) with those arguments. Including an initialized Module class is something I am more familiar with and have see in other code bases and generally prefer.
Penalty for bypassing the top-level Capitalized Methods
Initially I thought (and was told) I could bypass include Dry::Types() or Dry::Schema::Params() and just access the underlying Class/Module directly. Turns out that these top-level Capitalized Methods do additional things or pass additional default values into the underlying methods/classes, making it difficult to bypass them and access the underlying classes or methods directly. For example, Dry::Types() passes Types.container to Dry::Types::Module.new. This means if I were to bypass include Dry::Types() I would need to do include Dry::Types::Module.new(Dry::Types.container). So while yes one can technically bypass the top-level Capitalized Methods, there is a slight penalty to doing so, which makes it a less viable option. I feel like this could be improved by adding default arguments to the underlying Class constructors, so they match the method signatures of the top-level Capitalized Methods.
Another example, is when I needed to define a common Dry::Schema that would then be used to create both Dry::Schema::Params() (to be used in a Dry::Validation::Contract class) and Dry::Schema::JSON() for validating/coercing both HTTP params and JSON params, I learned that one does this via Dry::Schema::Params(parent: TheSchemaClass) and Dry::Schema::JSON(parent: TheSchemaClass). While it is amazing that this is possible with dry-schema, I found the additional parent: keyword argument kind of unnecessary. Ideally, I should be able to call Dry::Schema::Params(TheSchemaClass) or TheSchemaClass.for_params.
Proc vs. Class
It struck me as kind of odd that dry-types and dry-schema prefer creating Procs, but dry-validation defines validations as Classes. Furthermore, I couldn’t find an example of initializing a Dry::Validation::Contract with some additional state, that would warrant it being a class. Every example showed validator = ValidationClass.new and result = validator.call(params). I ended up having to add a def self.call(params) method to my Dry::Validation::Contract classes to initialize the contract and call it with the given params.
Monad vs. Declarative
While dry-types, dry-schema, and dry-validation use monadic style (aka
method-chaining) for defining data in a method-chaining style, Dry::Struct has declarative attributes. For my usage
of Dry::Types, Dry::Schema, and Dry::Validation, I didn’t really see the
benefits of monadic style for defining types or schema params; maybe I could be
wrong and there are exotic edge-cases where monadic style is necessary or the order of the chained method-calls is important. While monads
are certainly a valid way to construct logic, declarative attributes with Type
classes that can be nested or unioned is also a valid style, one which
beginners might be more familiar with.
I think dry-types could support defining types as class (which would accept additional type information via initialize or be defined as class attributes), and dry-schema could support declarative attributes for defining schema params (with type and other validation configuration passed in as keyword arguments).
This is a pattern I have used in
command_mapper and Ronin::Core::Params.
Incomplete Documentation
I constantly found myself searching the guides trying to find examples of what I wanted to do. While there are many examples of how to use dry-types, dry-schema, or dry-validation, I couldn’t find complete end-to-end examples showing how to fully use the libraries to validate params and handle errors. The dry-validation guides forgot to include an example of inspecting the Dry::Validation::Result object. I eventually had to check the API documentation for Dry::Validation::Result and infer that you check success? or failure?, and or render errors or call to_h (or output in the case of Dry::Schema::Result) to get the result.
Additionally, only a few methods or classes in the API documentation actually have @example tags.
Unusual Naming Conventions
I struggled a bit with some of the method name choices. I eventually figured out filled(:string) meant non-empty String. Or that I had to check result.success?/result.failure? instead of result.error?, or result.errors?, or result.has_errors?. Maybe if more aliases were added this might make the API less difficult for beginners to get used to.
Dry::Validation::Rule methods
I was slightly confused at first trying to understand how to write additional rule(...)s for Dry::Validation::Contracts. Normally I would expect key and value to be passed in as block arguments, but apparently the rule(...)s block is instance_eval or instance_execed in a Dry::Validation::Rule instance and key and value are methods. The other thing that caught me off guard was that key is actually a Dry::Validation::Failures object which is used for adding error messages, not the key Symbol name, but value is actually the value for the key; and values allows access to all other values. Also, you can specify multiple keys in rule(...), but it doesn’t seem to have any effect on thekey, value, or values objects.