Skip to content

Adds runtime type and value checking to method parameters and return values

License

Notifications You must be signed in to change notification settings

SamCarlberg/method_contracts

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

26 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

MethodContracts

A pure Ruby implementation of parameter and return value checking.

This adds runtime type checking and validation to method parameters and return values. By default, this gem only supports validating types and exact values, but custom matchers can be created and specified. Contracts can be specified on parameters using the params <param_name>, <contract> syntax; contracts on return values can be specified by returns <contract>, returns_a <Type>, or returns_an <Type> (eg returns_a String, returns_an ArrayOf(String)).

Contracts can be specified as a module/class, which will test for inheritance (param :foo, String); an explicit value, which will test for equality param :foo, 'A string'; a regular expression (param :foo, /An? (array|string)/); an array of elements that all match the same contract (param :foo, ArrayOf(String)); a hash with keys matching one contract and values matching another (param :hash, HashOf(Symbol, Integer)); or an array of contracts, which will test that any contract in that array matches (param :foo, [String, Symbol] matches when foo is either a string or a symbol).

If none of the built-in contract types work, you can subclass MethodContracts::Matchers::Base and define the match?(value) and to_s methods, then pass an instance of your custom matcher to the param or return call.

Installation

Add this line to your application's Gemfile:

gem 'method_contracts', git: '[email protected]:SamCarlberg/method_contracts.git'

And then execute:

$ bundle install

Or install it yourself as:

$ gem install method_contracts

Usage

Type checks

class C
  param :x, String
  param :y, Numeric
  param :z, Hash
  returns_an Array
  def takes_three(x, y, z)
    [x, y, z]
  end
end

C.new.takes_three('x', 1, { z: :z }) # => ['x', 1, { :z => :z }]
C.new.takes_three(:x, 1, { z: :z })  # => error!  x is a Symbol, but should be a String

Value checks

class C
  param :x, 1
  returns 'ok'
  def takes_specific_value(x)
    'ok'
  end
end

C.new.takes_specific_value(1) # => 'ok'
C.new.takes_specific_value(2) # => error!

Regex checks

class C
  param :value, /^mo[dmo]$/
  returns 'ok'
  def foo(value)
    'ok'
  end
end

C.new.foo('mod') # => 'ok'
C.new.foo('mom') # => 'ok'
C.new.foo('moo') # => 'ok'
C.new.foo('mop') # => error!

Array checks

class C
  param :strategy, %i[foo bar baz]
  returns 'ok'
  def takes_any_of(strategy)
    'ok'
  end
end

C.new.takes_any_of(:foo) # => 'ok'
C.new.takes_any_of(:bar) # => 'ok'
C.new.takes_any_of(:baz) # => 'ok'
C.new.takes_any_of(:wrong) # => error!

Array checks with nested matchers

class C
  param :x, [String, Symbol, 0, nil]
  returns 'ok'
  def takes_generic(x)
    'ok'
  end
end

C.new.takes_generic('string') # => 'ok'
C.new.takes_generic(:symbol) # => 'ok'
C.new.takes_generic(0) # => 'ok'
C.new.takes_generic(nil) # => 'ok'
C.new.takes_generic(1234) # => error!

Structure checks

Checks can be added to check the structure of array or hash parameters and return values. Use the ArrayOf helper method to define a contract that matches to every element of an array (it also checks that the parameter is itself an Array). Similarly, the HashOf helper method can be used to define a contract for every key and a separate contract for every value in the hash. Elementwise or key-/value-wise contracts can be specified as usual, so param :nested_array, ArrayOf(ArrayOf(Integer)) will only match an array of arrays like [[1, 2, 3], [4, 5, 6]] but not [[1, 2, 3], 4, 5, 6]

class C
  # :array takes an array of zero or more integers
  param :array, ArrayOf(Integer)
  param :hash, HashOf(Symbol, [String, Symbol])
  returns 'ok'
  def structured_args(array, hash)
    'ok'
  end

  param :array, ArrayOf(ArrayOf(Integer))
  returns 'ok'
  def accepts_nested_arrays(array)
    'ok'
  end
end

C.new.structured_args([], {}) # => 'ok'
C.new.structured_args([1, 2, 3], { foo: 'bar', bar: :baz }) # => 'ok'
C.new.structured_args(%w[1 2 3], {}) # => error!
C.new.structured_args([], { 'foo' => 'bar' }) # error!

C.new.accepts_nested_arrays([]) # => 'ok'
C.new.accepts_nested_arrays([[]]) # => 'ok'
C.new.accepts_nested_arrays([1, 2, 3]) # => error!  not a nested array
C.new.accepts_nested_arrays([[1, 2, 3]]) # => 'ok'

Custom matcher logic

class C
  param :x, ->(x) { x == [1, 2, 3] }
  returns 'ok'
  def custom_matcher_logic(x)
    'ok'
  end
end

C.new.custom_matcher_logic([1, 2, 3]) # => 'ok'
C.new.custom_matcher_logic([1, 2, 4]) # => error!

This can also be used by passing a block to the param or returns call. Note that param needs parens around the parameter name if using curly braces for the block definition.

class C
  param(:x) { |x| x == [1, 2, 3] }
  returns { |r| r == 'ok' }
  def custom_matcher_logic(x)
    'ok'
  end
end

Create a custom matcher

class PositiveNumber < MethodContracts::Matchers::Base
  def match?(value)
    value.is_a?(Numeric) && value > 0
  end

  def to_s
    'be a positive number'
  end
end

class UsingCustomMatcher
  param :bar, PositiveNumber
  def foo(bar)
    "ok"
  end
end

UsingCustomMatcher.new.foo(1) # => "ok"
UsingCustomMatcher.new.foo(0) # => MethodContracts::BrokenParamContractError: UsingCustomMatcher#foo.bar was 0, which does not match: be a positive number

Configuration

Type contracts are disabled by default, and can be enabled via the configuration object:

MethodContracts.configure do |config|
  config.enabled = true
end

This must be set before any classes that use type contracts are loaded.

Contracts can be added globally to all modules via config.include_everywhere!; however, be careful when using this since it can potentially cause conflicts with modules that already define methods named annotations, param, returns, returns_a, or returns_an.

Contracts can be added to specific modules by extending the MethodContracts::T module:

module MyModule
  extend MethodContracts::T

  # ... module contents ...
end

class MyClass
  extend MethodContracts::T

  # ... class contents ...
end

Development

After checking out the repo, run bin/setup to install dependencies. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/samcarlberg/method_contracts.

License

The gem is available as open source under the terms of the MIT License.

Alternatives

RBS

Ruby 3 introduced RBS, which is a built-in type system in the Ruby language. However, it requires maintaining a separate file listing method signatures for each Ruby file, which requires manual work to keep the separate files in sync.

Sorbet

Sorbet is probably better than this Gem. I just didn't like the DSL for it.

About

Adds runtime type and value checking to method parameters and return values

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published