DEV Community

Max Chernyak
Max Chernyak

Posted on • Originally published at max.engineer on

Adventures in Ruby-esque type enforcement

In Ruby you can kinda pretend that you have type enforcement at runtime, because Ruby is very flexible. This could be a useful-enough thing to do to organize and formalize the “guarding” of your data. As a disclaimer, I’m not actually a huge fan of this practice, because I think that if you’re going to enforce types at runtime, you may as well achieve the same result via learning how to write good constructors and immutable objects. I believe the focus should be on controlling the flow of data from source to destination, not declaring types to guard against every generic use case. Nevertheless, for many existing codebases out there, runtime-level types might be the right way to improve maintainability, so I decided to experiment with my own approach.

Again, to be clear, the above criticism is only about enforcing types at runtime, not at compile time.

Before I start, there are already libraries out there that let you declare types at runtime. They offer a bunch of fancy-named classes and methods that let you construct your own types. I disagree with their approach, because it introduces a lot of cognitive overhead. They expect me to learn all of that extra vocabulary only to end up running boolean expressions against my values. Why not just let me write those boolean expressions in the first place? This is the whole premise of my experiment: it seems easier to write a plain Ruby value check than to figure out how to build it with fancy type libraries.

A while ago, I wrote a little library called portrayal, which is a simple Struct-like object builder. It lets you declare keywords, which are just attr_accessors and a default initialize, plus some extra convenience. Using this lib as the basis, I wrote a proof of concept extension called Portrayal::Guards. In this article I show you how it works.

Leaning into boolean expressions

Let’s say we have a class Person, who has age and favorite_beer.

class Person
  extend Portrayal

  keyword :age
  keyword :favorite_beer, default: nil
  public :age=, :favorite_beer=
end
Enter fullscreen mode Exit fullscreen mode

Note: Normally setters are protected, but I’m making them public above to illustrate how guards work.

Imagine that our data type requirements are as follows:

  • Age must be an integer between 0 and 130
  • Favorite beer must be nil or any string
  • If favorite beer is not nil, then age must be >=21

Here is one simple way to do this with Portrayal::Guards.

class Person
  extend Portrayal

  keyword :age
  keyword :favorite_beer, default: nil
  public :age=, :favorite_beer=

  guard('age must be human and beer is only for >=21yo') {
    age.is_a?(Integer) && (0..130).cover?(age) &&
      (favorite_beer.nil? || (favorite_beer.is_a?(String) && age >= 21))
  }
end
Enter fullscreen mode Exit fullscreen mode

This guard can be declared anywhere in the class body. It has a single boolean expression in it. If it returns anything truthy, the guard passes. If it returns false or nil, the guard fails. The string argument serves as the error message in case it fails. With this single guard we actually solved the whole problem.

Check out how this guard protects our object:

> Person.new(age: 200)
ArgumentError: age must be human and beer is only for >=21yo

> person = Person.new(age: 5)
=> #<Person @age=5, @favorite_beer=nil>

> person.favorite_beer = 'corona'
ArgumentError: age must be human and beer is only for >=21yo

> person.update(age: 200, favorite_beer: 9)
=> {:base=>["age must be human and beer is only for >=21yo"]}

> person.update(age: 30, favorite_beer: 'corona')
=> nil

> person
=> #<Person @age=30, @favorite_beer="corona">
Enter fullscreen mode Exit fullscreen mode

Three things to notice here:

  1. This guard is guarding both initialize (.new), and writer methods.
  2. We have a special method update, which lets you update multiple values at the same time. This helps resolve situations when you can’t assign attributes one at a time, because guards cross-check them.
  3. Notice that the error we got from update is under a key :base. Keep it in mind for now, I will explain this later.

This was easy, it’s just a plain boolean expression that now completely guards our attributes. However, the expression is a little bit unwieldy, and the error message is not super useful for telling us what exactly is wrong. That’s okay. We can rewrite the guard into 3 separate guards.

guard('age must be an integer in human range') {
  age.is_a?(Integer) && (0..130).cover?(age)
}

guard('favorite_beer must be string or nil') {
  favorite_beer.nil? || favorite_beer.is_a?(String)
}

guard('favorite_beer is only allowed for age >=21') {
  favorite_beer.nil? || age >= 21
}
Enter fullscreen mode Exit fullscreen mode

Much neater. Let’s try running the same code:

> Person.new(age: 200)
ArgumentError: age must be an integer in human range

> person = Person.new(age: 5)
=> #<Person @age=5, @favorite_beer=nil>

> person.favorite_beer = 'corona'
ArgumentError: favorite_beer is only allowed for age >=21

> person.update(age: 200, favorite_beer: 9)
=> {:base=>["age must be an integer in human range", "favorite_beer must be string or nil"]}

> person.update(age: 30, favorite_beer: 'corona')
=> nil

> person
=> #<Person @age=30, @favorite_beer="corona">
Enter fullscreen mode Exit fullscreen mode

Nice, error messages are now more specific.

Just to recap, with guard and plain Ruby we can accomplish… everything. Okay, thanks, b…

But what about reuse?

Ah. Reuse is already here by default. We can have a module like this.

module ReusableTypes
  def int(name)
    guard("#{name} must be an integer") { send(name).is_a?(Integer) }
  end

  def age(name)
    int(name)
    guard("#{name} must be within 0-130") { (0..130).cover?(send(name)) }
  end

  def nullable_string(name)
    guard("#{name} must be nil or a string") {
      value = send(name)
      value.nil? || value.is_a?(String)
    }
  end
end

class Person
  extend Portrayal
  extend ReusableTypes

  keyword :age
  keyword :favorite_beer, default: nil
  public :age=, :favorite_beer=

  age :age
  nullable_string :favorite_beer

  guard('favorite_beer is only allowed for age >=21') {
    favorite_beer.nil? || age >= 21
  }
end
Enter fullscreen mode Exit fullscreen mode

We put guards in module methods and call them. Nothing really changed, but we suddenly have reusable types.

In Ruby it’s a common tradition to return the name of what’s being declared. Portrayal’s keyword follows this tradition, returning the name of the keyword. If you’d like, you can put our type methods in front of keyword, and it works the same.

class Person
  extend Portrayal
  extend ReusableTypes

  age keyword :age
  nullable_string keyword :favorite_beer, default: nil
  public :age=, :favorite_beer=

  guard('favorite_beer is only allowed for age >=21') {
    favorite_beer.nil? || age >= 21
  }
end
Enter fullscreen mode Exit fullscreen mode

If you don’t like the above style, you could do something else. For example, you could return name from methods in our module, and wrap the keyword names in them. Let’s also capitalize method names while at it:

module ReusableTypes
  def Int(name)
    guard("#{name} must be an integer") { send(name).is_a?(Integer) }
    name
  end

  def Age(name)
    Int(name)
    guard("#{name} must be within 0-130") { (0..130).cover?(send(name)) }
    name
  end

  def NullableString(name)
    guard("#{name} must be nil or a string") {
      value = send(name)
      value.nil? || value.is_a?(String)
    }
    name
  end
end
Enter fullscreen mode Exit fullscreen mode

Which makes this possible:

class Person
  extend Portrayal
  extend ReusableTypes

  keyword Age(:age)
  keyword NullableString(:favorite_beer), default: nil
  public :age=, :favorite_beer=

  guard('favorite_beer is only allowed for age >=21') {
    favorite_beer.nil? || age >= 21
  }
end
Enter fullscreen mode Exit fullscreen mode

When I said earlier that guards can be declared anywhere in the class body, I really meant it. This still works. I’m sure there are more ways you can come up with for using these guards. These are just a couple off the top of my head.

Looking at the above, you can probably already imagine how you’d be able to easily implement a type of any complexity, and Portrayal::Guards will make sure to guard your initializers and writers for you.

Okay, thanks, b…

But what about composition?

Right, we actually might need some extra features to make composition nice. After toying around with some ideas, I decided to include the following additional features into the proof of concept.

Guard chaining

One way to compose guards could be to make sure that our reusable methods return the passed-in name, like we already did above. If every declaration returns the name that it received, then we could chain guards like this:

# Type methods
def Odd(name)
  guard("#{name} must be odd") { value = send(name); value.respond_to?(:odd?) && value.odd? }
  name
end

def Int(name)
  guard("#{name} must be an integer") { send(name).is_a?(Integer) }
  name
end

# Chaining example:
Odd Int keyword :odd_number
Enter fullscreen mode Exit fullscreen mode

This could be especially nice for something like Nullable, where we don’t want to create NullableString, NullableInt, etc for every possible type. So maybe if we had

def Nullable(name)
  guard("#{name} can be nil") { send(name).nil? }
  name
end
Enter fullscreen mode Exit fullscreen mode

Then maybe we could write Nullable Int keyword :number?

Unfortunately, we cannot. It won’t work, because Nullable will fail anything that isn’t a nil, and Int will fail anything that isn’t an integer. They don’t mesh, because we don’t have full &&/|| capabilities across guards. The good news is that perhaps we don’t actually need them.

I’ve thought about a few ways to enable this sort of composition, and came up with what I find to be a simple solution: a pass! guard.

Special pass! guard

A pass! is just like a regular guard, you can have as many as you want (but you probably never need more than one), and they always run first. If a pass! returns anything truthy, then we’re done, the object is valid, no further guards are called. With this new capability we can make Nullable like this:

def Nullable(name)
  pass!("#{name} can be nil") { send(name).nil? }
  name
end
Enter fullscreen mode Exit fullscreen mode

And this kind of composition works now:

Nullable Int keyword :number, default: nil
Nullable String keyword :text, default: nil
Enter fullscreen mode Exit fullscreen mode

Yay!

Because a pass! always runs first, the order doesn’t matter. If a pass! sees nil, other guards won’t run. If it sees non-nil, then we proceed into int/string guards.

Unfortunately, there’s still a problem here. All the guards are mixed together, so the Nullable check for number will stop all guards from executing, even the String guard for text. That’s because we add guards into the class, but we aren’t grouping them with each other.

To solve this, I added guard grouping. But don’t worry, it’s basically nothing.

Guard grouping

Remember that :base key in the error hash you saw earlier? Here’s a reminder:

{:base=>["age must be human and beer is only for >=21yo"]}
Enter fullscreen mode Exit fullscreen mode

The :base is actually a default topic for guards. And it’s super simple to group guards into other topics. Just add one more argument to the guard:

guard(:topic_name, 'error message') { boolean expression }
Enter fullscreen mode Exit fullscreen mode

The new first argument :topic_name (it could be anything really) is the topic. So all guards are actually per topic. A fail or pass! in one topic won’t stop guards in another topic. This is just a more generic way to let you make guards “per attribute”. And of course it’s just what the doctor ordered for ReusableTypes module. We can now do this:

module ReusableTypes
  def int(name)
    guard(name, "#{name} must be an integer") { send(name).is_a?(Integer) }
  end

  def age(name)
    int(name)
    guard(name, "#{name} must be between 0 and 130") { (0..130).cover?(send(name)) }
  end

  def string(name)
    guard(name, "#{name} must be string") { send(name).is_a?(String) }
  end

  def nullable(name)
    pass!(name, "#{name} can be nil") { send(name).nil? }
  end
end
Enter fullscreen mode Exit fullscreen mode

By the way, notice how we’re no longer returning name from each method. That’s because each guard already returns its topic, so we don’t have to do that anymore. Another small win.

With these in place we can now declare our Person this way:

class Person
  extend Portrayal
  extend ReusableTypes

  age keyword :age
  nullable string keyword :favorite_beer, default: nil

  guard('favorite_beer is only allowed for age >=21') {
    favorite_beer.nil? || age >= 21
  }  
end
Enter fullscreen mode Exit fullscreen mode

Or this way if you made methods capitalized:

Age keyword :age
Nullable String keyword :favorite_beer, default: nil
Enter fullscreen mode Exit fullscreen mode

Or this way, if you like to keep keyword on the left:

keyword Age(:age)
keyword Nullable(String :favorite_beer), default: nil
Enter fullscreen mode Exit fullscreen mode

Or this way, if you don’t want to intefere with keywords:

Age :age
Nullable String :favorite_beer

keyword :age
keyword :favorite_beer, default: nil
Enter fullscreen mode Exit fullscreen mode

Or go back to plain guard declarations. Whatever you fancy.

Keep in mind, we only learned 2 methods so far: guard and pass! (well, maybe also update if you’re pedantic). The rest is just plain Ruby.

Listing guards

Just for fun, I wanted to be able to list guards declared on a class. It’s possible with Person.portrayal.list_guards, which returns the following:

> Person.portrayal.list_guards
=> {:age=>["age must be an integer", "age must be between 0 and 130"],
 :favorite_beer=>["favorite_beer can be nil", "favorite_beer must be string"],
 :base=>["favorite_beer is only allowed for age >=21"]}
Enter fullscreen mode Exit fullscreen mode

Where is this lib?

At the time of this writing the implementation is just a gist. I’m curious what people think about this before I make it into a proper gem. Let me know your thoughts. Too crazy? Or not crazy enough? :)

Top comments (0)