dry-rb / dry-types

Flexible type system for Ruby with coercions and constraints
https://dry-rb.org/gems/dry-types
MIT License
860 stars 134 forks source link

Intersection (&) support #442

Closed robhanlon22 closed 2 years ago

robhanlon22 commented 2 years ago

For completeness, dry-types should include an Intersection type along side its Sum type.

This PR is certainly not yet complete (refactoring and specs needed), but does pass some initial smoke tests from the console.

flash-gordon commented 2 years ago

We have dry-struct for this, don't we?

flash-gordon commented 2 years ago

Also, .constrained can be chained. It doesn't seem there's a practical need for combining two arbitrary types. I mean, I've been using dry-types for ages and I cannot recall a use case for this. Do you have any?

Other than this, I can see there's a temptation to have something like this in the lib.

robhanlon22 commented 2 years ago

That’s a good point. I was looking into some issues with nested composition of schemas over in dry-schema, and the approach to combining schemas is to merge their types into a Dry::Types::Schema; it seemed easiest to me to be able to conjoin the types that were Dry::Logic::Operations::And’ed together with an intersection operator; I’m all ears for other ideas! Thanks

alassek commented 2 years ago

I have been wanting this capability for a while, here's an example use-case.

I often express object interfaces, like

module Types
  Callable = Interface(:call)
  Procable = Interface(:to_proc)
  Function = Interface(:call, :to_proc)
end

Using a Sum here does not work. It would accept anything that responds to either #call or #to_proc, but does not enforce both.

You can express multiple interfaces at definition time, it would be really nice to compose two interfaces together.

Function = Callable & Procable
robhanlon22 commented 2 years ago

@alassek closing this because I won't have the time to work on it; feel free to use this initial work if you'd like to implement this! thanks

flash-gordon commented 2 years ago

I didn't have time to properly respond to this but in a nutshell, there's an issue with duplicated predicates. For example,

String.constrained(min_size: 5) & String.constrained(max_size: 10)

will check if valid input is a string two times. Deduplicating predicates is not a reliable solution since it will depend on the order of predicates. Moreover, types can be nested and combined in a tree structure.

OTOH, types like

String.constrained(min_size: 5) | String.constrained(max_size: 10)

have the same issue for invalid input. So maybe I'm just being over-cautious.

robhanlon22 commented 2 years ago

Yeah, there's always an issue with duplicated predicates. You can easily end up with never types, like something that is both true and false. However, I think that @alassek's example is a very good one; it's nice to be able to compose multiple types into a single type, like the compound interface in the example. dry-logic allows for this with its operations, and you can imagine combining two constrained strings:

ContainsFoo = String.constrained(format: /foo/)
IsLongEnough = String.constrained(min_size: 10)
ContainsFooAndIsLongEnough = ContainsFoo & IsLongEnough

One could argue that you could extract these constraints out to their parameters and merge them to create your new type, but that breaks out of the compositional thinking that you might use when sum / intersection types.

flash-gordon commented 2 years ago

There's also another concern, support for such types has to be added to dry-schema increasing its complexity. I mean generating error messages etc.

solnic commented 2 years ago

support for such types has to be added to dry-schema

No it doesn't have to be added :) We could add it maybe in the future, but if we decide to add them here, dry-schema could simply produce an exception saying "oops type not supported".

flash-gordon commented 2 years ago

I meant more like eventually

robhanlon22 commented 2 years ago

FWIW, I tried this branch out with no changes in dry-schema and it A) worked with no changes to the message compiler (because it already has visit_and) and it B) greatly simplified the complex TypesMerger code that I just wrote. Further convinces me that adding this and perhaps implication to Dry::Types would be a good move.