Initial impressions feedback after using dry-types, dry-schema, dry-validation in a new Sinatra app

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.

5 Likes

Thanks for the feedback!

That’s for DI. My validation classes often use dependencies (for instance, for querying the database etc). The classes are automatically registered, instantiated, and frozen by dry-system. Having a class makes it consistent with other components managed by a dry-system-powered app.

It’s not about monads, it’s about mutable vs immutable API. dry-validation and dry-schema also have a mutable API when defining a schema/contract. dry-types is more atomic and produces an immutable object on every call. It also makes it more composable. To my taste, complex types are a misuse of dry-types, a wrong design direction, I wouldn’t want having/supporting class-based types.

I think I know a good approach to this :slight_smile:

Does this mean that I cannot use DI with dry-schema, but can use it with dry-validation?

Is it impossible to create a immutable or atomic API using declarative syntax or types defined as class?

What would that be? I can’t exactly document APIs that I don’t fully understand.

A schema is an immutable object so there’s. You could inject something with effects but since dry-schema doesn’t have rules (from dry-validation) there’s simply no place for injection.

This is a good question. I think it’s possible. It would be more verbose, though.