Dec. 20, 2023, 2 p.m.

Simple Way to Use Types in Ruby

Sustainable Development and More

SocialMedia.jpg

This post by Evan Hahn shows how static typing in Crystal can result in removing some validations you might need in Ruby and thus static types can be a way shorten code.

Sure, sometimes! But, his example of Ruby code that could be shortened leaves some room for improvement. Ruby can be used to create rich and powerful data types, even if they aren't checked by a compiler.

Here's his code (formatted to not be so wide):

def initialize(min_length)
  unless min_length.is_a?(Integer) && 
         min_length >= 0 &&
         min_length <= 255
    raise "Minimum length has to be between 0 and 255"
  end

  @min_length = min_length
end

Ignore the is_a?(Integer) for a second and focus on the validation that min_length must be between 0 and 255. His Crystal code requires specifying the type, so he used the builtin type UInt8. This is an unsigned 8-bit value, so by definition cannot be outside the range of [0,255].

Ruby doesn't have this particular type, but we could certainly make it!

Ruby Is Great for Making Data Types

class UInt8
  def initialize(value)
    unless (0..255).include?(value)
      raise ArgumentError,
            "#{value} must be between 0 and 255"
    end
    @value = value
  end

  def to_i = @value
end

We don't require that value be an Integer since the range check implies that.

UInt8 is now a clearly defined datatype, which means that the original Ruby code could be:

def initialize(min_length_unit8)
  @min_length = min_length_uint8
end

I suffixed the value with the type name, as is customary. And sure, you could check that min_length_uint8 is a UInt8 if it were important, but it often isn't worth checking. It depends on how the value is used.

I do all this not to say that the Ruby version is equivalent to the Crystal version, but just that you can certainly define types in Ruby however you like. Just because Ruby doesn't have a built-in type for an 8-bit unsigned integer, doesn't mean you can't make one and use it.

But what it does mean is that by defining types you can write code that doesn't have to check if the primitive being passed is valid. Wrap the primitive in a type that does the checking!

Bonus: Make it Like a Real Integer

While our UInt8 can be coerced into an Integer via to_i, we could use SimpleDelegator to make it behave much more like one.

A class that extends SimpleDelegator passes an object to the constructor via super(...). Any method call on an instance of that class gets delegated to the wrapped object.

require 'delegate'

class UInt8 < SimpleDelegator
  def initialize(value)
    unless (0..255).include?(value)
      raise ArgumentError,
            "#{value} must be between 0 and 255"
    end
    super(value)
  end
end

x = UInt8.new(42)
puts x + 10    # => 52
puts 20.0 / x  # => 0.47619047619047616

This demonstrates one reason why Rubyists don't check the type of something. In this case, UInt8 may not by a subclass of Integer, but it behaves like one. This is the difference between nominal typing (types based names assigned to them, like UInt8) and structural typing (types based on the structure of the object, e.g. what methods the object responds to, also called duck typing).

Try It

Next time you are messing about with validating strings or hashes, try making a data type instead, and passing that to your core logic. By insulating your core logic with well-defined types, you can simplify the most complex part of your app…and you don't need a type-checking compiler to do it!

TIL

It's hard to keep up with everything, and sometimes I learn something that has been common knowledge for a while. Maybe this happens to you.

JavaScript's' arrow functions don't work like a function declared with function. I had previously thought arrow functions were just syntactic sugar. They aren't, especially when it comes to this

In JavaScript, the value of this can be complicated. When you declare a function with function, you can later change what this inside that function refers to:

class Functions {
  constructor() {
    this.foo = "foo!"
    this.method = function() { return this.foo }
  }
}
const f = new Functions()
const x = f.method
console.log(x())
// TypeError: Cannot read properties of undefined

console.log(x.bind(f)())              // => "foo!"
console.log(x.bind({ foo: "bar" })()) // => "bar"
console.log(x.bind({ foo: 42 })())    // => 42

With arrow functions, you can'd use bind this way. Well, you can, it just doesn't have any effect:

class Functions {
  constructor() {
    this.foo = "foo!"
    this.method = () => { return this.foo }
  }
}
const f = new Functions()
const y = f.method
console.log(y())                      // => "foo!"
console.log(y.bind(f)())              // => "foo!"
console.log(y.bind({ foo: "bar" })()) // => "foo!"
console.log(y.bind({ foo: 42 })())    // => "foo!"

I found this behavior subtle, especially since bind was able to be called without an error. But…today I learned!


Unless otherwise noted, my emails were written entirely by me without any assistance from a generative AI.

You just read issue #2 of Sustainable Development and More. You can also browse the full archives of this newsletter.

Share on LinkedIn Share on Hacker News Share on Reddit Share via email
Website
Powered by Buttondown, the easiest way to start and grow your newsletter.