Playing with typed collections using dry-types

Jacek Galanciak

October 08, 2017

ruby,experiments

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 do
2enabled_editions.map(&:to_sym).include?(:modern)
3
4# What we want
5enabled_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 Types
2 include Dry::Types.module
3end
4
5Types::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 post
10# => "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 << 123
6# => ["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.
1require 'dry-types'
2
3module Types
4 include Dry::Types.module
5end
6
7class TypedArray < Array
8 attr_reader :_type
9
10 # Make Types::Object as the default type. This is a "bypass" type
11 def initialize(type = Types::Object, array = [])
12 @_type = type
13 # Declare array type
14 array_type = Types::Strict::Array.member(_type)
15 # Coerce the input
16 typed_array = array_type[array]
17 # Initialize the array from coerced collection
18 super(typed_array)
19 end
20
21 def append(obj)
22 (self << obj).last
23 end
24
25 def <<(obj)
26 super(_type[obj])
27 end
28
29 def insert(index, obj)
30 super(index, _type[obj])
31 end
32
33 def concat(arr)
34 super(arr.map { |e| _type[e] })
35 end
36end

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

Jacek Galanciak © 2023