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.
Before I start, there are already libraries out there that let you declare types to be checked 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 an extensive vocabulary only to describe simple boolean expressions. 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_accessor
s 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
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
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:
# Trying to init a person with invalid age
> Person.new(age: 200)
ArgumentError: age must be human and beer is only for >=21yo
# Making a valid person
> person = Person.new(age: 5)
=> #<Person @age=5, @favorite_beer=nil>
# Trying to assign a beer to <21yo with a setter method
> person.favorite_beer = 'corona'
ArgumentError: age must be human and beer is only for >=21yo
# Method `update` lets you apply multiple changes at once, in this case invalid
> person.update(age: 200, favorite_beer: 9)
=> {:base=>["age must be human and beer is only for >=21yo"]}
# Valid `update`
> person.update(age: 30, favorite_beer: 'corona')
=> nil
> person
=> #<Person @age=30, @favorite_beer="corona">
Three things to notice here:
- This guard is guarding both
initialize
(.new
), and writer methods. - 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. - 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
}
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">
Nice, error messages are now more specific.
Just to recap, with guard
and plain Ruby we can accomplish… everything.
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=
# Calling the guards!
age :age
nullable_string :favorite_beer
guard('favorite_beer is only allowed for age >=21') {
favorite_beer.nil? || age >= 21
}
end
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
# Calling the guards inline with keywords!
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
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
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
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.
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
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
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
And this kind of composition works now:
Nullable Int keyword :number, default: nil
Nullable String keyword :text, default: nil
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"]}
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 }
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
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
Or this way if you made methods capitalized:
Age keyword :age
Nullable String keyword :favorite_beer, default: nil
Or this way, if you like to keep keyword on the left:
keyword Age(:age)
keyword Nullable(String :favorite_beer), default: nil
Or this way, if you don’t want to interfere with keywords:
Age :age
Nullable String :favorite_beer
keyword :age
keyword :favorite_beer, default: nil
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"]}
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? :)