Skip to content

Instantly share code, notes, and snippets.

@dorianmariecom
Created July 3, 2021 12:49
Show Gist options
  • Save dorianmariecom/b7ce6f9cb5328b38bcdf52d2f491ab63 to your computer and use it in GitHub Desktop.
Save dorianmariecom/b7ce6f9cb5328b38bcdf52d2f491ab63 to your computer and use it in GitHub Desktop.
require "rspec"
class Class
def |(other)
Type.new(self, other)
end
def class_of?(object)
object.is_a?(self)
end
end
class Type
class Error < Exception
end
def initialize(*types)
@types = types
end
def class_of?(object)
@types.any? { |type| type.class_of?(object) }
end
end
def check_types(method_name, locals, types)
locals = Array(locals)
types = Array(types)
[locals.size, types.size].max.times do |index|
if !types[index].class_of?(locals[index])
raise Type::Error
end
end
end
def add(a1, a2)
check_types(:add, [a1, a2], [Integer, Integer])
a1 + a2
end
class User
def name
end
def fetch
end
end
def fetch(user)
check_types(:fetch, user, User)
user.fetch
end
def username(user)
check_types(:username, user, User | NilClass)
user&.name
end
RSpec.describe do
it { add(10, 5) }
it { expect { add("cool", 243) }.to raise_error(Type::Error) }
it { expect { add(nil, 243) }.to raise_error(Type::Error) }
it { fetch(User.new) }
it { expect { fetch(nil) }.to raise_error(Type::Error) }
it { username(User.new) }
it { username(nil) }
it { expect { username(123) }.to raise_error(Type::Error) }
end
@JoshCheek
Copy link

I played with it a bit and came up with this:

Description of refactorings

  • Swap class_of? for === b/c then it will work for anything you can put in a case statement, eg procs and modules.
  • Move the Type class to Type::Or b/c you could also imagine wanting multiple constraints, leading to a Type::And
  • Explicitly check the lengths of the arrays, because if you had more locals than types, then it would have exploded with class_of? and now with === it will incorrectly pass if the local is nil, b/c ruby -e 'p 2.times.all? { |i| [Integer][i] === [1, nil][i] }' but that should have been a wrong number of arguments.
  • explicitly wrap in an array over using Array(locals) because an arg of nil or [] should be [nil] and [[]], but Array(locals) would change them both to [], changing the number of locals. Decide whether to do this or not based on the types, since the types are given explicitly and the locals are forwarded from the caller.
  • We can match the elements up with zip since we now know they have the same size
  • Type::Error inherits from StandardError instead of Exception
  • Type::Error can be defined in a single line Error = Class.new StandardError
  • Move the | method to Type, to avoid monkey patching
  • Remove Type::Error in favour of TypeError because there's already an exception for it, and people will expect it to raise that error, and b/c I want to be able to include Type, but Error feels too generic of a name to include into other contexts. Then include Type into Class to keep everything working.
  • Move the inclusion of Type from Class into Module so you can do things like Comparable | Enumerable
  • Have Type inclusion into Module happen in a refinement so that there is no modification of core classes, add a using Type to allow everything to continue working.
  • Discover a maybe bug while doing that, the refinement has to happen after the methods are defined.
  • Move check_types into its own Module and include it onto Kernel in a refinement, again, to avoid monkey patching
  • Add a caller_name method (note that it's super expensive), and set it as the default value of check_types first argument, method_name, so that you can omit the method name and it will be calculated.
  • Extend Types::Check onto itself so we can call caller_name from check_types (since this happens in a refinement, it's lexically scoped, so check_types can't see that Types::Check is included into Kernel, so it can't call caller_name... also maybe a bug) However, since caller_name is private, check_types can't say Types::Check.caller_name... so use public_class_method to make it public on Types::Check, but private on things that Types::Check is included into.

Final result

require "rspec"

module Type
  def |(other)
    Type::Or.new(self, other)
  end
end

module Type::Check
  extend self

  private

  # lol, note this is super expensive
  public_class_method def caller_name
    (raise rescue $!).backtrace_locations.map(&:base_label).uniq[2]
  end

  private def check_types(method_name=Type::Check.caller_name, locals, types)
    locals, types = [locals], [types] unless types.is_a? Array
    raise ArgumentError unless locals.size == types.size
    locals.zip types do |local, type|
      raise TypeError, "#{method_name} type error" unless type === local
    end
  end
end

class Type::Or
  def initialize(*types)
    @types = types
  end

  def ===(object)
    @types.any? { |type| type === object }
  end
end

module Type
  refine(Module) { include Type }
  refine(Kernel) { include Type::Check }
end

using Type

def add(a1, a2)
  check_types(:add, [a1, a2], [Integer, Integer])
  a1 + a2
end

class User
  def name
  end

  def fetch
  end

  def to_str
    'a user'
  end

  def to_enum
    E
  end
end

def fetch(user)
  check_types(:fetch, user, User)
  user.fetch
end

def username(user)
  check_types(:username, user, User | NilClass)
  user&.name
end

def with_proc(stringable)
  check_types(:with_proc, stringable, -> o { o.respond_to? :to_str })
  stringable.to_str
end

def incorrect_call
  check_types __method__, [1, 2, nil], [Integer, Integer]
end

def passes_scalar_array(ary)
  check_types __method__, ary, Array
  ary.size
end

def module_or(arg)
  check_types __method__, arg, Enumerable | Comparable
end

def without_passing_name
  check_types 1, String
end

RSpec.describe do
  it { add(10, 5) }
  it { expect { add("cool", 243) }.to raise_error(TypeError) }
  it { expect { add(nil, 243) }.to raise_error(TypeError) }
  it { fetch(User.new) }
  it { expect { fetch(nil) }.to raise_error(TypeError) }
  it { expect { fetch(nil) }.to raise_error(TypeError) }
  it { username(User.new) }
  it { username(nil) }
  it { expect { username(123) }.to raise_error(TypeError) }
  it { expect { with_proc 123 }.to raise_error(TypeError) }
  it { expect(with_proc User.new).to eq 'a user' }
  it { expect { incorrect_call }.to raise_error ArgumentError }
  it { expect(passes_scalar_array []).to eq 0 }
  it { module_or [] }
  it { module_or 123 }
  it { expect { module_or // }.to raise_error TypeError }
  it { expect { without_passing_name }.to raise_error TypeError, /without_passing_name/ }
end

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment