Story behind strong_arguments

TL;DR Describing typical problems with ruby function arguments. How I solved them with a gem. What other options are.

Ruby syntax

keyword arguments

Ruby 2.0 introduced first-class support for keyword arguments:

def foo(bar: 'default')
  puts bar
end

foo # => 'default'
foo(bar: 'baz') # => 'baz'

In Ruby 1.9, we could do something similar using a single Hash parameter:

def foo(options = {})
  bar = options.fetch(:bar, 'default')
  puts bar
end

foo # => 'default'
foo(bar: 'baz') # => 'baz'

Ruby 2.1 introduced required keyword arguments, which are defined with a trailing colon:

def foo(bar:)
  puts bar
end

foo # => ArgumentError: missing keyword: bar
foo(bar: 'baz') # => 'baz'

foo(bar: 'baz', baz: 'baz') # => ArgumentError: unknown keyword: baz

Optional named arguments

def foo(bar:, baz: nil)
  puts bar, baz
end

foo(bar: 'baz', baz: 'baz') # => 'baz' 'baz'

def foo(bar:, **args)
  puts bar, args.fetch(:baz)
end

foo(bar: 'baz', baz: 'baz', other: 'other') # => 'baz' 'baz'

No way to distinguish between nil and no value

Assume we have method similar to ActiveRecords’ where, so it can accept different parameters to filter results.

def where(name: nil)
  if name.nil?
    # filter
  else
    # all results
  end
end

where() # all records

bar = 'baz'
where(name: bar) # only records with name == 'baz'

bar = nil
where(name: bar) # expect records with name == nil, but instead it will give all records

Solution:

def where(options = {})
  if options.key?(:name)
    # filter
  else
    # all results
  end
end

# Ruby 2.0 syntax
def where(**options)
  options = options || {}

  if options.key?(:name)
    # filter
  else
    # all results
  end
end

No way to check allowed parameters to prevent typos

Take a look at code from previous section at different angle

def where(name: nil)
end

where(nane: nil) # ArgumentError: unknown keyword: nane

def where(options = {})
end

where(nane: nil) # expect ArgumentError, but instead it will give all records

Solution:

def where(options = {})
  allowed_arguments = [:name]
  extra_arguments = options.keys - allowed_arguments
  raise ArgumentError.new("unknown keyword: #{extra_arguments[0]}") if extra_arguments.length > 0

  if options.key?(:name)
    # filter
  else
    # all results
  end
end

where(nane: nil) # ArgumentError: unknown keyword: nane

No way to do only one optional parameter

Assume you have really broad database of products. It includes products with different types of SKU numbers. There is no sense to search by more than one identifier: where(upc: '123', ean: '456')

Solution:

def where(options = {})
  raise ArgumentError.new("One and only one keyword required") if options.length != 1
  allowed_arguments = [:upc, :ean, :isbn]
  extra_arguments = options.keys - allowed_arguments
  raise ArgumentError.new("unknown keyword: #{extra_arguments[0]}") if extra_arguments.length > 0

  if options.key?(:upc)
    # filter
  else
    # all results
  end
end

Some code snippets taken from thoughtbout article.

Gem

I extracted all those snippets into strong_arguments gem and added some DSL inspired by strong_parameters. Work in progress though.

Usage

require 'strong_arguments'

def where(options = {})
  arguments = StrongArguments.new(options)
    .optional(:name, :age)

  if argument.name_present?
    argument.name
  elsif argument.age_present?
    argument.age
  end
end

Notice: work in progress. Examples below may not work.

Types

It is possible to add type checking in manner of stronger_parameters.

def where(options = {})
  arguments = StrongArguments.new(options)
    .optional(name: StrongArguments.string, age: StrongArguments.integer)
end

Defaults

It is possible to add defaults. Example of how it can look like

def where(options = {})
  arguments = StrongArguments.new(options)
    .optional(name: StrongArguments.string.default('Joe'), age: StrongArguments.integer.default(10))
end

Other view to the problem: method signatures DSL

sig

sig adds the sig method that allows you to add signatures to Ruby methods.

sig [:to_i, :to_i], Integer
def sum(a, b)
  a.to_i + b.to_i
end

sum(42, false) # Sig::ArgumentTypeError: Expected false to respond to :to_i

This solution introduce gradual and structural type system.

See also:

Rubype

Rubype brings you advantage of type without changing existing code’s behavior.

require 'rubype'

class MyClass
  def sum(x, y)
    x + y
  end
  typesig :sum, [Numeric, Numeric] => Numeric
end

MyClass.new.sum(1, 'string') # Rubype::ArgumentTypeError: for MyClass#sum's 2nd argument

RTC

Rtc Ruby Type Checker

Looking into the future

Static type checker

Interestingly, Matz suggested that this kind of system would initially be implemented as a static analyzer, and that it would require new restrictions on some common Ruby methods, such as require and methods_missing, in order to support a type-checked universe. It isn’t totally clear what those restrictions would need to be, but it’s a fun thought experiment.

Matz at RubyConf 2014: Will Ruby 3.0 Be Statically Typed?

Ruby static type analyzer can be implemented the same way as flow for JavaScript.

See also:

  • issue 9999
  • https://codon.com/consider-static-typing
  • https://www.cs.umd.edu/projects/PL/druby/papers/druby-oops09.pdf
  • https://blog.abevoelker.com/sick-of-ruby-dynamic-typing-side-effects-object-oriented-programming/

Auto-generated documentation

Method signatures will allow to automatically generate documentation for methods

desc 'Sums two values'
sig [:to_i, :to_i], Integer
def sum(a, b)
  a.to_i + b.to_i
end

Similar thing expressed in YARD

# Sums two values
#
# @param a [Integer] first value
# @param b [Integer] second value
# @return [Integer]

Or mixed with YARD

# Sums two values
#
# @author Loren Segal
# @deprecated Use `+` instead
sig [:to_i, :to_i], Integer
def sum(a, b)
  a.to_i + b.to_i
end

Enforced semantic visioning

Idea comes from elm. See also: semantic versioning.