Overcoming Our Obsession With Stringly-Typed Ruby

Dave Copeland · @davetron5000

http://www.naildrivin5.com · http://technology.stitchfix.com

Overcoming Our Obsession With
Stringly-Typed Ruby

Stringly-Typed

From @codinghorror's “New Programmer Jargon”

http://blog.codinghorror.com/new-programming-jargon/

A riff on “strongly typed”.

Used to describe an implementation that needlessly relies on strings when programmer & refactor-friendly options are available.

A tale of two zip codes

U.S. postal codes

aka “Zip Codes”

Zip Codes

Simple App

module ThirdPartyMailingService
def self.find_letter(letter_id) # ... end
class Letter def mail!(street,city,state,zip_code) # ... end end end
module ThirdPartyMailingService
  def self.find_letter(letter_id)
    # ...
  end

class Letter def mail!(street,city,state,zip_code) # ... end end
end
create table addresses(
  id       int          not null,
  street   varchar(256) not null,
  city     varchar(256) not null,
  state    char(2)      not null,
  zip_code char(5)      not null
)
class AddressReader
  def read_new_address
    puts "Enter your address"

puts "street?" street = gets
puts "city?" city = gets puts "state?" state = gets puts "zip code?" zip_code = gets id = $database.insert_into(:addresses, street: street, city: city, state: state, zip_code: zip_code) puts "Created address #{id}" end end
class AddressReader
  def read_new_address
    puts "Enter your address"

    puts "street?"
    street   = gets

    puts "city?"
    city     = gets

    puts "state?"
    state    = gets

    puts "zip code?"
    zip_code = gets

id = $database.insert_into(:addresses, street: street, city: city, state: state, zip_code: zip_code)
puts "Created address #{id}" end end
class LetterSender
  def send_letter(letter_id,address_hash)
letter = ThirdPartyMailingService.find_letter(letter_id)
letter.mail!(address_hash[:street], address_hash[:city], address_hash[:state], address_hash[:zip_code]) end end
class LetterSender
  def send_letter(letter_id,address_hash)
    letter = ThirdPartyMailingService.find_letter(letter_id)
letter.mail!(address_hash[:street], address_hash[:city], address_hash[:state], address_hash[:zip_code])
end end
class Mailer
  def send_letter(address_id,letter_id)
address_hash = $database.select_from(:addresses, :id => address_id)
LetterSender.new.send_letter(letter_id, to: address_hash) puts "Letter ##{letter_id} sent to" puts address_hash[:street] puts "#{address_hash[:city], #{address_hash[:state]}, #{address_hash[:zip_code]}" end end Mailer.new.send_letter(ARGV[0].to_i, ARGV[1].to_i)
class Mailer
  def send_letter(address_id,letter_id)
    address_hash = $database.select_from(:addresses,
                                         :id => address_id)
LetterSender.new.send_letter(letter_id, to: address_hash)
puts "Letter ##{letter_id} sent to" puts address_hash[:street] puts "#{address_hash[:city], #{address_hash[:state]}, #{address_hash[:zip_code]}" end end Mailer.new.send_letter(ARGV[0].to_i, ARGV[1].to_i)
>  ./store_address.rb
street?
>  45 S. Fair Oaks Ave
city?
>  Beverley Hills
state?
>  CA
zip?
>  90210
You created address 42
>  ./send_letter.rb 42 12
Letter #12 sent to
45 S. Fair Oaks Ave
Beverley Hills, CA 90210
>  ./store_address.rb
street?
>  1675 E Altadena Dr
city?
>  Altadena
state?
>  CA
zip?
>  WALSH
You created address 43
>  ./send_letter.rb 43 12
Letter #12 sent to
1675 E Altadena Dr
Altadena, CA WALSH

Oops

Must be a problem reading input!

zip_code = gets

unless zip_code =~ /^[0-9]{5}$/ raise "not a zip code" end
INSERT INTO
  addresses(street,city,state,zip)
VALUES
  ('1675 E Altadena Dr'
   'Altadena',
   'CA',
   'WALSH')

Oh! Oh!

A problem in our database design!

ALTER TABLE
  addresses
ADD CONSTRAINT
  looks_like_a_zipcode
CHECK ( (zip ~* '^[0-9]{5}$') )
LetterSender.new.send_letter(letter_id,
    street: '1675 E Altadena Dr'
      city: 'Altadena',
     state: 'CA',
  zip_code: 'WALSH')

This is not how to build a maintainable system

Boundaries

What's coming and what's going

Data Types

Data Types

String Zip Code
Possible values Anything Five Digits
Operations A ton Pretty much none
What They Mean Anything Location in the U.S.
class ZipCode
  def initialize(string)
    @raw_zipcode = string
  end
end
class ZipCode
  def initialize(string)
    unless string =~ /^\d\d\d\d\d$/
      raise 'invalid zipcode' 
    end
    @raw_zipcode = string
  end
end
zip1 = ZipCode.new("90210")

zip2 = ZipCode.new("WALSH")
# => boom
# zip2 never gets assigned
class ZipCode
  def raw_zipcode
    @raw_zipcode
  end
end

Design Activity

How do we publicize these boundaries?

Conventions

class FooBar

  # zip_code:: instance of ZipCode to geolocate
  def geolocate(zip_code)
  end

  # Returns a ZipCode nearest the given lat and long
  def nearest_zip_code(lat,long)
  end

private

  def center_point(zip_code)
  end
end

Can we enforce this?

class FooBar
  def geolocate(zip_code)
unless zip_code.is_a?(ZipCode)
raise ArgumentError, "must be a ZipCode" end # ... end end
class FooBar
  def geolocate(zip_code)
unless zip_code.instance_of?(ZipCode)
raise ArgumentError, "must be a ZipCode" end # ... end end
class FooBar
  def geolocate(zip_code)
unless zip_code.respond_to?(:raw_zipcode)
raise ArgumentError, "must implement #raw_zipcode" end # ... end end
class FooBarTest
  def test_that_we_get_the_right_data_type
    result = foo_bar.nearest_zip_code(lat,long)
assert result.is_a?(ZipCode),
"Got a #{result.class} instead :(" end end

Should we?

Two Times You Might

Risk

Big Refactor (w/compiler)

Big Refactor (no compiler)

When The World Wants/Has Strings

Protocols

www.confidentruby.com

Chapter 4.2

class ZipCode
  def initialize(string)
    unless string =~ /^\d\d\d\d\d$/
      raise 'invalid zipcode' 
    end
    @raw_zipcode = string
  end

  def raw_zipcode
    @raw_zipcode
  end
end
>  zip = ZipCode.new("90210")
<ZipCode:0x007fb02d8b0968 @raw_zipcode="90210">
>  puts zip
<ZipCode:0x007fb02d8b0968>
>  puts "The zip is #{zip}"
The zip is <ZipCode:0x007fb02d8b0968>
class ZipCode
  alias to_s raw_zipcode
end
>  puts "The zip is #{zip}"
The zip is 90210
>  puts "The zip is " + zip
TypeError: no implicit conversion of ZipCode into String
	from (irb):12:in `+'
	from (irb):12
	from /Users/davec/.rvm/rubies/ruby-2.1.0/bin/irb:11:in `<main>'
class ZipCode
  alias to_str raw_zipcode
end
>  puts "The zip is " + zip
The zip is 90210
<dt>ZipCode</dt>
<dd><%= zip_code %></dd>

Non-String Goodies, too

Wrappers

class Mailer
  def send_letter(address_id,letter_id)
    address_hash = $database.select_from(:addresses,
                                         :id => address_id)

    LetterSender.new.send_letter(letter_id, to: address_hash)
  end
end
class WrappingDatabase < SimpleDelegator
  def convert(table: table, column: column, to: type)
@conversions[table][column] = type
end end $database = WrappingDatabase.new($database) $database.convert table: :addresses, column: :zip_code, to: ZipCode
class WrappingDatabase < SimpleDelegator
  def select_from(table, where)
    raw_results = __getobj__.select_from(table,where)
    convert_results(table,raw_results)
  end
end
def convert_results(table,raw_results)
  raw_results.map { |row_hash|
    convert_row(table,row_hash)
  }
end
def convert_row(table,row_hash)
  new_row = {}
  row_hash.each do |column,value|
    if @conversions[table][column]
      # e.g. ZipCode.new("90210")
      new_row[column] = @conversions[table][column].new(value)
    else
      new_row[column] = value
    end
  end
  new_row
end

Active Record

class Address < ActiveRecord::Base
  serialize :zip_code, ZipCode
end
class ZipCode
  def self.load(raw_string)
    self.new(raw_string)
  end

  def self.dump(zip_code)
    zip_code.to_str
  end
end
address = Address.find(43) # has the bad zipcode
<ArgumentError "must be a ZipCode">

Sequel

require 'sequel/plugins/serialization'
Sequel::Plugins::Serialization.register_format(
  :zip_code_serialization,

  ->(zip_code)   { zip_code.to_str         }, # AKA dump
  ->(raw_string) { ZipCode.new(raw_string) }  # AKA load
)

class Address < Sequel::Model
  plugin :serialization
  serialize_attributes :zip_code_serialization, :zip_code
end

Other Opportunites

EmailAddress

>  "dave@stitchfix.com" == "Dave@StitchFix.com"
 => false 
>  "dave@stitchfix.com".downcase == "Dave@StitchFix.com".downcase #sigh
 => true 
>  EmailAddress.new("dave@stitchfix.com") == EmailAddress.new("Dave@StitchFix.com")
 => true

Prices

>  third = Rational(1,3)
>  (100 * third).to_f
33.333333333336
>  Price.new(100).discount(third) 
<Price value=33.33>

Enumerated Types

State/Province, Status Codes, Error Codes

Use Data Types

Define Your Boundaries

Put Your Knowledge and Intent in Code

THANKS!

http://naildrivin5.com/stringly-typed-ruby/

My Blog - http://www.naildrivin5.com/blog

Get A New Job at http://technology.stitchfix.com/jobs