Playing with typed collections using dry-types
Things that make Ruby a particularly enticing language can as well become its bane in context of large projects. Dynamic typing enables passing around objects and methods without caring about their type but also makes it unpredictable and requires additional code to coerce proper types.
1# What we often have to do2enabled_editions.map(&:to_sym).include?(:modern)34# What we want5enabled_editions.include?(:modern)
There's a great variety of possible origins of data stored in enabled_editions
array. Could be HTTP request parameters, a database or a JSON fetched from an API. But could as well be a locally-constructed object in an expected form: an array of symbols. Having to enforce the code manually dilutes the meaning of the code, making it harder to understand. Performance is an additional consideration: doing type coercions all the time can potentially be much slower than doing the coercion once and always doing further operations on a coerced collection.
Understanding dry-types
Let's experiment with what dry-types
has to offer:
1module Types2 include Dry::Types.module3end45Types::Strict::String.call("hi")6# => "hi"7Types::Strict::String["hi"]8# `[]` is an alias to `call`.9# This form will be used for the rest of this post10# => "hi"11Types::Strict::String[nil]12# => Dry::Types::ConstraintError: nil violates constraints (type?(String, nil) failed)13Types::Coercible::String[5]14# => "5"15Types::Coercible::String[nil]16# => ""
Interestingly, the library does support arrays but only on the initial stage of type enforcing:
1Types::Strict::Array.member(Types::Strict::String)[[1, :foo, "bar"]]2# => Dry::Types::ConstraintError: [1, :foo, "bar"] violates constraints…3array = Types::Strict::Array.member(Types::Coercible::String)[[1, :foo, "bar"]]4# => ["1", "foo", "bar"]5array << 1236# => ["1", "foo", "bar", 123] # 123 is not coerced
Implementing a typed array
We're going to subclass Array
with the following changes:
- A collection should store its type.
- A collection should enforce types of all objects we add to it.
1require 'dry-types'23module Types4 include Dry::Types.module5end67class TypedArray < Array8 attr_reader :_type910 # Make Types::Object as the default type. This is a "bypass" type11 def initialize(type = Types::Object, array = [])12 @_type = type13 # Declare array type14 array_type = Types::Strict::Array.member(_type)15 # Coerce the input16 typed_array = array_type[array]17 # Initialize the array from coerced collection18 super(typed_array)19 end2021 def append(obj)22 (self << obj).last23 end2425 def <<(obj)26 super(_type[obj])27 end2829 def insert(index, obj)30 super(index, _type[obj])31 end3233 def concat(arr)34 super(arr.map { |e| _type[e] })35 end36end
That's a small piece of code. Let's see if it works:
1strings = TypedArray.new(Types::Coercible::String, [1, "test", :foo])2# => ["1", "test", "foo"]3strings << 54# => ["1", "test", "foo", "5"]5strings.concat [7, :bar]6# => ["1", "test", "foo", "5", "7", "bar"]
Let's try some more restrictive collection:
1TypedArray.new(Types::Strict::String, [1, "test"])2# Dry::Types::ConstraintError: [1, "test"] violates constraints…34strict_array = TypedArray.new(Types::Strict::String, ["1", "test"])5# => ["1", "test"]6strict_array << "bar"7# => ["1", "test", "bar"]8strict_array << :foo9# Dry::Types::ConstraintError: :foo violates constraints (type?(String, :foo) failed)
Works as expected!
Is it any useful?
While I wouldn't use this class too often directly, it's a good mechanism to handle collections of your entities and aggregate roots, if you decide to practice domain-driven design. I'm going to write about implementing a simple entity framework from scratch pretty soon, so stay tuned!