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
2
3
4
5
# What we often have to do
enabled_editions.map(&:to_sym).include?(:modern)

# What we want
enabled_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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
module Types
  include Dry::Types.module
end

Types::Strict::String.call("hi")
# => "hi"
Types::Strict::String["hi"]
# `[]` is an alias to `call`.
# This form will be used for the rest of this post
# => "hi"
Types::Strict::String[nil]
# => Dry::Types::ConstraintError: nil violates constraints (type?(String, nil) failed)
Types::Coercible::String[5]
# => "5"
Types::Coercible::String[nil]
# => ""

Interestingly, the library does support arrays but only on the initial stage of type enforcing:

1
2
3
4
5
6
Types::Strict::Array.member(Types::Strict::String)[[1, :foo, "bar"]]
# => Dry::Types::ConstraintError: [1, :foo, "bar"] violates constraints…
array = Types::Strict::Array.member(Types::Coercible::String)[[1, :foo, "bar"]]
# => ["1", "foo", "bar"]
array << 123
# => ["1", "foo", "bar", 123] # 123 is not coerced

Implementing a typed array

We’re going to subclass Array with the following changes:

  1. A collection should store its type.
  2. A collection should enforce types of all objects we add to it.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
require 'dry-types'

module Types
  include Dry::Types.module
end

class TypedArray < Array
  attr_reader :_type

  # Make Types::Object as the default type. This is a "bypass" type
  def initialize(type = Types::Object, array = [])
    @_type = type
    # Declare array type
    array_type = Types::Strict::Array.member(_type)
    # Coerce the input
    typed_array = array_type[array]
    # Initialize the array from coerced collection
    super(typed_array)
  end

  def append(obj)
    (self << obj).last
  end

  def <<(obj)
    super(_type[obj])
  end

  def insert(index, obj)
    super(index, _type[obj])
  end

  def concat(arr)
    super(arr.map { |e| _type[e] })
  end
end

That’s a small piece of code. Let’s see if it works:

1
2
3
4
5
6
strings = TypedArray.new(Types::Coercible::String, [1, "test", :foo])
# => ["1", "test", "foo"]
strings << 5
# => ["1", "test", "foo", "5"]
strings.concat [7, :bar]
# => ["1", "test", "foo", "5", "7", "bar"]

Let’s try some more restrictive collection:

1
2
3
4
5
6
7
8
9
TypedArray.new(Types::Strict::String, [1, "test"])
# Dry::Types::ConstraintError: [1, "test"] violates constraints…

strict_array = TypedArray.new(Types::Strict::String, ["1", "test"])
# => ["1", "test"]
strict_array << "bar"
# => ["1", "test", "bar"]
strict_array << :foo
# 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!

Posted on October 8, 2017 in ruby, experiments

comments powered by Disqus