dry-data
A simple and extendible type system for Ruby with support for kernel coercions, form coercions, sum types, constrained types and default-value types.
Used by:
- dry-validation for params coercions
- rom-repository for auto-mapped structs
- rom's adapters for relation schema definitions
- your project...?
Articles:
dry-data vs virtus
Virtus has been a successful library, unfortunately it is "only" a by-product of an ActiveRecord ORM which carries many issues typical to ActiveRecord-like features that we all know from Rails, especially when it comes to very complicated coercion logic, mixing unrelated concerns, polluting application layer with concerns that should be handled at the bounderies etc.
dry-data
has been created to become a better tool that solves similar (but
not identical!) problems related to type-safety and coercions. It is a superior
solution because:
- Types are categorized, which is especially important for coercions
- Types are objects and they are easily reusable
- Has structs and values with a simple DSL
- Has constrained types
- Has optional types
- Has defaults
- Has sum-types
- Has enums
- Has hash type with type schemas
- Has array type with member type
- Suitable for many use-cases while remaining simple, in example:
- Params coercions
- Domain "models"
- Defining various domain-specific, shared information using enums or values
- Annotating objects
- and more...
- There's no const-missing magic and complicated const lookups like in Virtus
- AND is roughly 10-12x faster than Virtus
Installation
Add this line to your application's Gemfile:
gem 'dry-data'
And then execute:
$ bundle
Or install it yourself as:
$ gem install dry-data
Usage
You can use dry-data
for defining various data types in your application, like
domain entities and value objects or hashes with coercible values used to handle
params.
Built-in types are grouped under 5 categories:
- default: pass-through without any checks
strict
- doesn't coerce and checks the input type against the primitive classcoercible
- tries to coerce and raises type-error if it failedform
- non-strict coercion types suitable for form paramsmaybe
- accepts either a nil or something else
Configuring Types Module
In dry-data
a type is an object with a constructor that knows how to handle
input. On top of that there are high-level types like a sum-type, constrained type,
optional type or default value type.
To acccess all the built-in type objects you can configure dry-data
with a
namespace module:
module Types
end
Dry::Data.configure do |config|
config.namespace = Types
end
# after defining your custom types (if you've got any) you can finalize setup
Dry::Data.finalize
# this defines all types under your namespace, in example:
Types::Coercible::String
# => #<Dry::Data::Type:0x007feffb104aa8 @constructor=#<Method: Kernel.String>, @primitive=String>
With types accessible as constants you can easily compose more complex types, like sum-types or constrained types, in hash schemas or structs:
Dry::Data.configure do |config|
config.namespace = Types
end
Dry::Data.finalize
module Types
Email = String.constrained(format: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z]+)*\.[a-z]+\z/i)
Age = Int.constrained(gt: 18)
end
class User < Dry::Data::Struct
attribute :name, Types::String
attribute :email, Types::Email
attribute :age, Types::Age
end
Built-in Type Categories
Assuming you configured types under Types
module namespace:
Non-coercible:
Types::Nil
Types::Symbol
Types::Class
Types::True
Types::False
Types::Date
Types::DateTime
Types::Time
Coercible types using kernel coercion methods:
Types::Coercible::String
Types::Coercible::Int
Types::Coercible::Float
Types::Coercible::Decimal
Types::Coercible::Array
Types::Coercible::Hash
Optional strict types:
Types::Maybe::Strict::String
Types::Maybe::Strict::Int
Types::Maybe::Strict::Float
Types::Maybe::Strict::Decimal
Types::Maybe::Strict::Array
Types::Maybe::Strict::Hash
Optional coercible types:
Types::Maybe::Coercible::String
Types::Maybe::Coercible::Int
Types::Maybe::Coercible::Float
Types::Maybe::Coercible::Decimal
Types::Maybe::Coercible::Array
Types::Maybe::Coercible::Hash
Coercible types suitable for form param processing:
Types::Form::Nil
Types::Form::Date
Types::Form::DateTime
Types::Form::Time
Types::Form::True
Types::Form::False
Types::Form::Bool
Types::Form::Int
Types::Form::Float
Types::Form::Decimal
Strict vs Coercible Types
Types::Strict::Int[1] # => 1
Types::Strict::Int['1'] # => raises Dry::Data::ConstraintError
# coercible type-check group
Types::Coercible::String[:foo] # => 'foo'
Types::Coercible::Array[:foo] # => [:foo]
# form group
Types::Form::Date['2015-11-29'] # => #<Date: 2015-11-29 ((2457356j,0s,0n),+0s,2299161j)>
Optional Types
All built-in types have their optional versions too, you can access them under
"Types::Maybe::Strict"
and "Maybe::Coercible"
categories:
Types::Maybe::Int[nil] # None
Types::Maybe::Int[123] # Some(123)
Types::Maybe::Coercible::Float[nil] # None
Types::Maybe::Coercible::Float['12.3'] # Some(12.3)
You can define your own optional types too:
maybe_string = Types::Strict::String.optional
maybe_string[nil]
# => None
maybe_string[nil].fmap(&:upcase)
# => None
maybe_string['something']
# => Some('something')
maybe_string['something'].fmap(&:upcase)
# => Some('SOMETHING')
maybe_string['something'].fmap(&:upcase).value
# => "SOMETHING"
Defaults
A type with a default value will return the configured value when the input is nil
:
PostStatus = Types::Strict::String.default('draft')
PostStatus[nil] # "draft"
PostStatus["published"] # "published"
PostStatus[true] # raises ConstraintError
Sum-types
You can specify sum types using |
operator, it is an explicit way of defining
what are the valid types of a value.
In example dry-data
defines bool
type which is a sum-type consisting of true
and false
types which is expressed as Types::True | Types::False
(and it has its strict version, too).
Another common case is defining that something can be either nil
or something else:
nil_or_string = Types::Nil | Types::Strict::String
nil_or_string[nil] # => nil
nil_or_string["hello"] # => "hello"
Constrained Types
You can create constrained types that will use validation rules to check if the input is not violating any of the configured contraints. You can treat it as a lower level guarantee that you're not instantiating objects that are broken.
All types support constraints API, but not all constraints are suitable for a particular primitive, it's up to you to set up constraints that make sense.
Under the hood it uses dry-logic
and all of its predicates are supported.
string = Types::Strict::String.constrained(min_size: 3)
string['foo']
# => "foo"
string['fo']
# => Dry::Data::ConstraintError: "fo" violates constraints
email = Types::Strict::String.constrained(
format: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z]+)*\.[a-z]+\z/i
)
email["[email protected]"]
# => "[email protected]"
email["jane"]
# => Dry::Data::ConstraintError: "fo" violates constraints
Enums
In many cases you may want to define an enum. For example in a blog application
a post may have a finite list of statuses. Apart from accessing the current status
value it is useful to have all possible values accessible too. Furthermore an
enum is a int => value
map, so you can store integers somewhere and have them
mapped to enum values conveniently.
# assuming we have types loaded into `Types` namespace
# we can easily define an enum for our post struct
class Post < Dry::Data::Struct
Statuses = Types::Strict::String.enum('draft', 'published', 'archived')
attribute :title, Types::Strict::String
attribute :body, Types::Strict::String
attribute :status, Statuses
end
# enum values are frozen, let's be paranoid, doesn't hurt and have potential to
# eliminate silly bugs
Post::Statuses.values.frozen? # => true
Post::Statuses.values.all?(&:frozen?) # => true
# you can access values using indices or actual values
Post::Statuses[0] # => "draft"
Post::Statuses['draft'] # => "draft"
# it'll raise if something silly was passed in
Post::Statuses['something silly']
# => Dry::Data::ConstraintError: "something silly" violates constraints
# nil is considered as something silly too
Post::Statuses[nil]
# => Dry::Data::ConstraintError: nil violates constraints
Hashes
The built-in hash type has constructors that you can use to define hashes with explicit schemas and coercible values using the built-in types.
Hash Schema
# using simple kernel coercions
hash = Types::Hash.schema(name: Types::String, age: Types::Coercible::Int)
hash[name: 'Jane', age: '21']
# => { :name => "Jane", :age => 21 }
# using form param coercions
hash = Types::Hash.schema(name: Types::String, birthdate: Form::Date)
hash[name: 'Jane', birthdate: '1994-11-11']
# => { :name => "Jane", :birthdate => #<Date: 1994-11-11 ((2449668j,0s,0n),+0s,2299161j)> }
Strict Schema
Strict hash will raise errors when keys are missing or value types are incorrect.
hash = Types::Hash.strict(name: 'string', age: 'coercible.int')
hash[email: '[email protected]', name: 'Jane', age: 21]
# => Dry::Data::SchemaKeyError: :email is missing in Hash input
Symbolized Schema
Symbolized hash will turn string key names into symbols
hash = Types::Hash.symbolized(name: Types::String, age: Types::Coercible::Int)
hash['name' => 'Jane', 'age' => '21']
# => { :name => "Jane", :age => 21 }
Arrays
The built-in array type supports defining member type:
PostStatuses = Types::Strict::Array.member(Types::Coercible::String)
PostStatuses[[:foo, :bar]] # ["foo", "bar"]
Structs
You can define struct objects which will have readers for specified attributes using a simple dsl:
class User < Dry::Data::Struct
attribute :name, Types::Maybe::Coercible::String
attribute :age, Types::Coercible::Int
end
user = User.new(name: nil, age: '21')
user.name # None
user.age # 21
user = User(name: 'Jane', age: '21')
user.name # => Some("Jane")
user.age # => 21
Values
You can define value objects which will behave like structs and have equality methods too:
class Location < Dry::Data::Value
attribute :lat, Types::Strict::Float
attribute :lat, Types::Strict::Float
end
loc1 = Location.new(lat: 1.23, lng: 4.56)
loc2 = Location.new(lat: 1.23, lng: 4.56)
loc1 == loc2
# true
Status and Roadmap
This library is in an early stage of development but you are encouraged to try it out and provide feedback.
For planned features check out the issues.
Development
After checking out the repo, run bin/setup
to install dependencies. Then, run rake spec
to run the tests. You can also run bin/console
for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install
. To release a new version, update the version number in version.rb
, and then run bundle exec rake release
, which will create a git tag for the version, push git commits and tags, and push the .gem
file to rubygems.org.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/dryrb/dry-data.