Constrain
Constrain
allows you to check if an object match a class expression. It is
typically used to check the type of method parameters and is an alternative to
using Ruby-3 .rbs files
require 'constrain'
include Constrain
# f takes a String and an array of Integer objects and raises otherwise
def f(a, b)
constrain a, String
constrain b, [Integer]
...
end
f("Hello", [1, 2]) # Doesn't raise
f("Hello", "world") # Boom
It is intended to be an aid in development only and to be deactivated in production (TODO: Make it possible to deactivate)
Constrain works with ruby-3
Usage
You will typically include Constrain globally to have #constrain available everywhere
require 'constrain'
# Include globally to make #constrain available everywhere
include Constrain
def f(a, b, c)
constrain a, Integer # An integer
constrain b, { [Symbol, String] => Integer } # Hash with String or Symbol keys
constrain c, [String], NilClass # Array of strings or nil
...
end
Constrain only defines the methods #constrain and #constrain? so it is acceptible in most cases. An alternative is to include the constrain module in a common root class to have it available in all child classes:
class BaseClass
include Constrain
...
end
Methods
constrain(value, *expressions, message: nil, unwind: 0)
Return the given value if it matches at least one of the expressions and raise a Constrain::TypeError if not. The value is matched against the expressions using the #=== operator so anything you can put into the 'when' clause of a 'case' statement can be used. #constrain raise a Constrain::MatchError if the value doesn't match any expression and an ArgumentError if there is a syntax error in the expression
The error message can be customized by adding the message option and a number of backtrace leves can be skipped using the :unwind option. By default the backtrace will refer to the point of the call of #constrain. #constrain raises a Constrain::Error exception if there is an error in the syntax of the class expression
#constrain is typically used to type-check parameters in methods where you
want an exception if the parameters doesn't match the expected, but because it
returns the value if successful it can be used to check the validity of
variables in expressions too, eg. return constrain(result_of_complex_computation, Integer)
Constrain.constrain(value, *expressions, message: nil, unwind: 0)
Class method version of #constrain. It is automatically added to classes that include Constrain
Constrain.constrain?(value, *expressions) -> true or false
It matches value against the class expressions like #constrain but returns true or false as result. It is automatically added to classes that include Constrain. Constrain.constrain? raises a ArgumentError exception if there is an error in the syntax of the expression
Expressions
Expressions can be simple values, class expressions, or lambdas. You can mix simple values and class expressions but not lambdas
Simple expressions
Simple values is an easy way to check arguments with a limited set of allowed values like
def print_color(color)
constrain color, :red, :yellow, :green
...
end
Simple values are compared to the expected result using the #=== operator. This means you can use regular expressions too:
# Simple (!) email address regular expression (https://stackoverflow.com/a/719543)
EMAIL_ADDRESS_RE = /^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9.-]+$/
def send_email(address)
constrain address, EMAIL_ADDRESS_RE
...
end
Class Expressions
Constrain#constrain and Constrain::constrain? use class expressions composed of
class or module objects, Proc objects, or arrays and hashes of class expressions. Class or module
objects match if value.is_a?(class_or_module)
returns true:
constrain 42, Integer # Success
constrain 42, Comparable # Success
constrain nil, Comparable # Failure
More than one class expression is allowed. It matches if at least one of the expressions match:
constrain "str", Symbol, String # Success
constrain :sym, Symbol, String # Success
constrain 42, Symbol, String # Failure
Arrays
Arrays match if the value is an Array and all its element match the given class expression:
constrain [42], [Integer] # Success
constrain [42], [String] # Failure
Arrays can be nested
constrain [[42]], [[Integer]] # Success
constrain [42], [[Integer]] # Failure
More than one element class is allowed
constrain ["str"], [String, Symbol] # Success
constrain [:sym], [String, Symbol] # Success
constrain [42], [String, Symbol] # Failure
Note that [
... ]
is treated specially in hashes
Hashes
Hashes match if value is a hash and every key/value pair match one of the given key-class/value-class expressions:
constrain({"str" => 42}, { String => Integer }) # Success
constrain({"str" => 42}, { String => String }) # Failure
Note that the parenthesis are needed because otherwise the Ruby parser would interpret the hash as block argument to #constrain
Hash keys or values can also be lists of class expressions that match if any
expression match. List are annotated as an array but contains more than one
element so that [String, Symbol]
matches either a String or a Symbol value
while [String]
matches an array of String objects:
constrain({ sym: 42 }, { [Symbol, String] => Integer }) # Success
constrain({ [sym] => 42 }, { [Symbol, String] => Integer }) # Failure
To specify an array of Symbol or String objects in hash keys or values, make sure the list expression is enclosed in an array:
constrain({ [sym] => 42 }, { [[Symbol, String]] => Integer }) # Success
nil, true and false
NilClass is a valid argument and can be used to allow nil values:
constrain nil, Integer # Failure
constrain nil, Integer, NilClass # Success
Boolean values are a special case since ruby doesn't have a boolean type use a list to match for a boolean argument:
constrain true, TrueClass, FalseClass # Success
constrain false, TrueClass, FalseClass # Success
constrain nil, TrueClass, FalseClass # Failure
But note that it is often easier to use value expressions:
constrain true, true, false # Success
constrain false, true, false # Success
constrain nil, true, false # Failure
constrain nil, true, false, nil # Success
Lambda expressions
Proc objects are called with the value as argument and should return truish or falsy:
constrain 42, lambda { |value| value > 1 } # Success
constrain 0, lambda { |value| value > 1 } # Failure
Note that it is not possible to first match against a class expression and then use the proc object. You will either have to check for the type too in the proc object or make two calls to #constrain:
constrain 0, Integer # Success
constrain 0, lambda { |value| value > 1 } # Failure
Alternatively, you can use Constrain::constrain? to mix classes or value with lambdas:
constrain 0, lambda { |value| Constrain::constrain?(Integer) && value > 1 } # Failure
Note that even though Proc objects can check every aspect of an object, you should not overuse it because as checks becomes more complex they tend to include business logic that should be kept in the production code. Constrain is only thouhgt of as a tool to catch developer errors - not errors that stem from corrupted data
Other uses
Constrain can be used to type-check complex structures like YAML documents:
# A YAML value
value = {
"str" => "a",
"int" => 42,
"arr" => [1, 2],
"hash" => {
"key1" => "b",
"key2" => 42
}
}
# Type description
type = {
"str" => String,
"int" => Integer,
"arr" => [Integer],
"hash" => {
"key1" => [String, Integer],
"key2" => [String, Integer]
}
}
puts constrain?(value, type) ? "yes" : "no" # Outputs 'yes'
value["str"] = 42
puts constrain?(value, type) ? "yes" : "no" # Outputs 'no'
Installation
Add this line to your application's Gemfile:
gem 'constrain'
And then execute:
$ bundle install
Or install it yourself as:
$ gem install constrain
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/clrgit/constrain.