A simple type system for Ruby with support for coercions.
Add this line to your application's Gemfile:
gem 'dry-data'
And then execute:
$ bundle
Or install it yourself as:
$ gem install dry-data
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
Non-coercible:
nil
symbol
class
true
false
date
date_time
time
Coercible types using kernel coercion methods:
coercible.string
coercible.int
coercible.float
coercible.decimal
coercible.array
coercible.hash
Optional strict types:
maybe.strict.string
maybe.strict.int
maybe.strict.float
maybe.strict.decimal
maybe.strict.array
maybe.strict.hash
Optional coercible types:
maybe.coercible.string
maybe.coercible.int
maybe.coercible.float
maybe.coercible.decimal
maybe.coercible.array
maybe.coercible.hash
Coercible types suitable for form param processing:
form.nil
form.date
form.date_time
form.time
form.true
form.false
form.bool
form.int
form.float
form.decimal
# default passthrough category
float = Dry::Data["float"]
float[3.2] # => 3.2
float["3.2"] # "3.2"
# strict type-check category
int = Dry::Data["strict.int"]
int[1] # => 1
int['1'] # => raises TypeError
# coercible type-check group
string = Dry::Data["coercible.string"]
array = Dry::Data["coercible.array"]
string[:foo] # => 'foo'
array[:foo] # => [:foo]
# form group
date = Dry::Data["form.date"]
date['2015-11-29'] # => #<Date: 2015-11-29 ((2457356j,0s,0n),+0s,2299161j)>
All built-in types have their optional versions too, you can access them under
"maybe.strict"
and "maybe.coercible"
categories:
maybe_int = Dry::Data["maybe.strict.int"]
maybe_int[nil] # None
maybe_int[123] # Some(123)
maybe_coercible_float = Dry::Data["maybe.coercible.float"]
maybe_int[nil] # None
maybe_int['12.3'] # Some(12.3)
You can define your own optional types too:
maybe_string = Dry::Data["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"
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 Dry::Data['true'] | Dry::Data['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 = Dry::Data['strict.nil'] | Dry::Data['strict.string']
nil_or_string[nil] # => nil
nil_or_string["hello"] # => "hello"
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-validation
and all of its predicates are supported.
IMPORTANT: dry-data
does not have a runtime dependency on dry-validation
so
if you want to use contrained types you need to add it to your Gemfile
If you want to use constrained type you need to require it explicitly:
require "dry/data/type/constrained"
string = Dry::Data["strict.string"].constrained(min_size: 3)
string['foo']
# => "foo"
string['fo']
# => Dry::Data::ConstraintError: "fo" violates constraints
email = Dry::Data['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
Types can be stored as easily accessible constants in a configured namespace:
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
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
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.
You can define enums for every type but it probably only makes sense for string
:
# 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
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.
# using simple kernel coercions
hash = Dry::Data['hash'].schema(name: 'string', age: 'coercible.int')
hash[name: 'Jane', age: '21']
# => { :name => "Jane", :age => 21 }
# using form param coercions
hash = Dry::Data['hash'].schema(name: 'string', birthdate: 'form.date')
hash[name: 'Jane', birthdate: '1994-11-11']
# => { :name => "Jane", :birthdate => #<Date: 1994-11-11 ((2449668j,0s,0n),+0s,2299161j)> }
Strict hash will raise errors when keys are missing or value types are incorrect.
hash = Dry::Data['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 hash will turn string key names into symbols
hash = Dry::Data['hash'].symbolized(name: 'string', age: 'coercible.int')
hash['name' => 'Jane', 'age' => '21']
# => { :name => "Jane", :age => 21 }
You can define struct objects which will have attribute readers for specified attributes using a simple dsl:
class User < Dry::Data::Struct
attribute :name, "maybe.coercible.string"
attribute :age, "coercible.int"
end
# becomes available like any other type
user_type = Dry::Data["user"]
user = user_type[name: nil, age: '21']
user.name # None
user.age # 21
user = user_type[name: 'Jane', age: '21']
user.name # => Some("Jane")
user.age # => 21
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.
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.
Bug reports and pull requests are welcome on GitHub at https://github.com/dryrb/dry-data.