sums_up
Sum types for Ruby with zero runtime dependencies. Inspired by hojberg/sums-up.
- What is a Sum Type?
- Quick Start
- Defining Sum Types
- Predicates
- Pattern Matching with Hashes
- Pattern Matching with Blocks
- Methods on Sum Types
- Variant Instance Methods
- A Note on Mutability
- Maybes
- Results
- Why?
- Development
- Contributing
- License
- Code of Conduct
What is a Sum Type?
Sum types are data structures with multiple variants. Ruby does not have sum types, but many concepts in the language (like booleans, integers, errors, state machines, etc.) can be described using sum types. Sum types are not limited to those use-cases, however, and are a powerful tool for modeling domain-specific data as well.
This README uses non-generalized examples of sum types to help build an intuition for when they might be useful. To learn more about sum types, I recommend watching Philip Wadler's Category Theory for the Working Hacker and checking out Elm's Custom Types, Haskell's Sum Types, Rust's Enums, and the Wikipedia article on Algebraic data types.
Quick Start
Define a sum type:
Direction = SumsUp.define(:north, :south, :east, :west)
# => Direction
Direction.north
# => #<variant Direction::North>
Direction.south
# => #<variant Direction::South>
Direction.east
# => #<variant Direction::East>
Direction.west
# => #<variant Direction::West>
Use predicates to distinguish between variants:
def latitudinal?(direction)
direction.north? ||
direction.south?
end
latitudinal?(Direction.south)
# => true
latitudinal?(Direction.west)
# => false
Call #match
to categorically handle each variant by name:
def turn_clockwise(direction)
direction.match do |m|
m.north { Direction.east }
m.south { Direction.west }
m.east { Direction.south }
m.west { Direction.north }
end
end
turn_clockwise(Direction.north)
# => #<variant Direction::East>
turn_clockwise(turn_clockwise(Direction.north))
# => #<variant Direction::South>
Defining Sum Types
Imagine we're writing software for a coffee shop. The menu might look something like this:
Item | Small | Large |
---|---|---|
Water | Free | |
Lemonade | $3.50 | $4.50 |
Coffee (Hot or Iced) | $2.95 | $3.95 |
To model the menu using sum types, let's start out with some simple enumerations:
Size = SumsUp.define(:small, :large)
# => Size
Temperature = SumsUp.define(:hot, :iced)
# => Temperature
Size.small
# => #<variant Size::Small>
Size.large
# => #<variant Size::Large>
Temperature.hot
# => #<variant Temperature::Hot>
Temperature.iced
# => #<variant Temperature::Iced>
Enumerations work well for Size
and Temperature
, but defining a Drink
type will be a bit more work.
There are multiple kinds of drinks (water, lemonade, and coffee), each of which has a varying set of attributes (some drinks are available in multiple sizes, coffee can be served hot or iced).
To describe these relationships, let's define a sum type with variants who have members.
In the below example, Drink.water
has no members, Drink.lemonade
has a size
, and Drink.coffee
has a size
and temperature
:
Drink = SumsUp.define(
:water,
lemonade: :size,
coffee: [:size, :temperature]
)
# => Drink
Drink.water
# => #<variant Drink::Water>
lemonade = Drink.lemonade(Size.small)
# => #<variant Drink::Lemonade size=#<variant Size::Small>>
lemonade.size
# => #<variant Size::Small>
coffee = Drink.coffee(Size.large, Temperature.iced)
# => #<variant Drink::Coffe size=#<variant Size::Large> temperature=#<variant Temperature::Iced>>
coffee.size
# => #<variant Size::Large>
coffee.temperature
# => #<variant Temperature::Iced>
# Raises because only coffee and lemonade have a size.
Drink.water.size
# => NoMethodError: undefined method `size' for #<variant Drink::Water>
Predicates
Predicates are defined for each variant of a sum type:
Size.large.large?
# => true
Temperature.hot.iced?
# => false
Temperature.iced.iced?
# => true
Drink.water.coffee?
# => false
# Raises because Temperature only has `#hot?` and `#iced?` predicates.
Temperature.hot.water?
# => NoMethodError: undefined method `water?' for #<variant Temperature::Hot>
We can use these to write a function which returns the Temperature
for a given Drink
.
Drink.coffee
is the only variant which has an explicit temperature
attribute, but we know that both Drink.water
and Drink.lemonade
are only served iced.
def drink_temperature(drink)
if drink.coffee?
drink.temperature
else
Temperature.iced
end
end
drink_temperature(Drink.lemonade(Size.large))
# => #<variant Temperature::Iced>
drink_temperature(Drink.coffee(Size.small, Temperature.hot))
# => #<variant Temperature::Hot>
Pattern Matching with Hashes
Another way to distinguish sum type variants is pattern matching.
We can use pattern matching with Hashes to define formatters for Size
and Temperature
:
def format_size(size)
size.match(small: 'Small', large: 'Large')
end
def format_temperature(temperature)
temperature.match(hot: 'Hot', iced: 'Iced')
end
format_size(Size.large)
# => 'Large'
format_temperature(Temperature.iced)
# => 'Iced'
In some cases, it can be convenient to match against some variants and use a wildcard for the rest:
def free?(drink)
drink.match(water: true, _: false)
end
free?(Drink.water)
# => true
free?(Drink.lemonade(Size.large))
# => false
#match
will raise if any variants are left unmatched.
The following method does not handle Drink.water
and will raise whenever any drink is provided:
def added_sugar?(drink)
drink.match(lemonade: true, coffee: false)
end
# Raises because water is not matched.
added_sugar?(Drink.water)
# => SumsUp::UnmatchedVariantError: Did not match the following variants: water
# Raises because water is not matched, even though a lemonade is getting passed in.
added_sugar?(Drink.lemonade(Size.large))
# => SumsUp::UnmatchedVariantError: Did not match the following variants: water
Pattern Matching with Blocks
Matching against the variant name is often not enough, we need to be able to use the variant's members as well.
For these use-cases, #match
accepts a block.
For variants with members, each member is yielded to the #match
block:
def format_drink(drink)
drink.match do |m|
m.water { 'Water' }
m.lemonade { |size| "#{format_size(size)} Lemonade" }
m.coffee do |size, temperature|
"#{format_size(size)} #{format_temperature(temperature)} Coffee"
end
end
end
format_drink(Drink.water)
# => 'Water'
format_drink(Drink.lemonade(Size.small))
# => 'Small Lemonade'
format_drink(Drink.coffee(Size.large, Temperature.iced))
# => 'Large Iced Coffee'
Like Hash-based pattern matching, Block-based pattern matching can use wildcards as well.
The below example redefines drink_temperature
using pattern matching:
def drink_temperature(drink)
drink.match do |m|
m.coffee { |_size, temperature| temperature }
m._ { Temperature.iced }
end
end
drink_temperature(Drink.water)
# => #<variant Temperature::Iced>
Note: if using the wildcard pattern matcher (_
), it must come after the explicit variant matches.
The match syntax also supports passing values directly to the matcher, as opposed to passing a block:
# Waters are always small, other drinks use their specified size.
def drink_size(drink)
drink.match do |m|
m.water Size.small
m.lemonade { |size| size }
m.coffee { |size, _temperature| size }
end
end
drink_size(Drink.water)
# => #<variant Size::Small>
drink_size(Drink.lemonade(Size.small))
# => #<variant Size::Small>
This syntax will also raise if not all variants of a type are matched:
def drink_price(drink)
drink.match do |m|
m.water 0
m.lemonade { |size| size.match(small: 350, large: 450) }
end
end
# Raises because coffee is not matched.
drink_price(Drink.coffee(Size.large, Temperature.hot))
# => SumsUp::UnmatchedVariantError: Did not match the following variants: coffee
# Raises because coffee is not matched, even though a water is getting passed in.
drink_price(Drink.water)
# => SumsUp::UnmatchedVariantError: Did not match the following variants: coffee
Methods on Sum Types
When defining a sum type, we can add methods to it by passing a block to SumsUp.define
:
Drink = SumsUp.define(:water, lemonade: :size, coffee: [:temperature, :size]) do
def price_in_cents
match do |m|
m.water 0
m.lemonade { |size| size.match(small: 350, large: 450) }
m.coffee { |size, _temperature| size.match(small: 295, large: 395) }
end
end
end
Drink.water.price_in_cents
# => 0
Drink.lemonade(Size.small).price_in_cents
# => 350
Drink.coffee(Size.large, Temperature.hot).price_in_cents
# => 395
This syntax also supports class methods and constants:
Size = SumsUp.define(:small, :large) do
SMALL_STRING = 'Small'.freeze
LARGE_STRING = 'Large'.freeze
def self.parse(str)
case str
when SMALL_STRING
small
when LARGE_STRING
large
else
raise ArgumentError, "Invalid size: #{str}"
end
end
end
Size::SMALL_STRING
# => 'Small'
Size::LARGE_STRING
# => 'Large'
Size.parse('Small')
# => #<variant Size::Small>
Size.parse('Trenta')
# => ArgumentError: Invalid size: Trenta
Variant Instance Methods
In addition to user-defined methods, #inspect
, and #==
, variant instances come with convenience methods for accessing and updating members.
Getters
Fetch a variant's members by name:
coffee = Drink.coffee(Size.small, Temperature.hot)
coffee.size
# => #<variant Size::Small>
coffee.temperature
# => #<variant Temperature::Hot>
lemonade = Drink.lemonade(Size.large)
lemonade.size
# => #<variant Size::Large>
# Lemonade does not have a 'temperature' member.
lemonade.temperature
# => NoMethodError: undefined method `temperature' for #<variant Drink::Lemonade size=#<variant Size::Large>>
Another way to access members is #[]
:
coffee = Drink.coffee(Size.small, Temperature.iced)
coffee[:size]
# => #<variant Size::Small>
# #[] works with Strings as well.
lemonade = Drink.lemonade(Size.large)
lemonade['size']
# => #<variant Size::Large>
# #[] will raise given an invalid member.
lemonade[:temperature]
# => NameError: No member 'temperature' in variant lemonade.
Setters
Members may also be updated by name:
coffee = Drink.coffee(Size.small, Temperature.hot)
coffee.temperature = Temperature.iced # Oh, sorry, could you make that iced?
coffee.temperature
# => #<variant Temperature::Iced>
#[]=
can also update a member:
lemonade = Drink.lemonade(Size.large)
lemonade['size'] = Size.small # Oh, a large is 32oz?
lemonade.size
# => #<variant Size::Small>
lemonade[:temperature] = Temperature.hot # Sorry, we don't do that here.
# => NameError: No member 'temperature' in variant lemonade.
#attributes
Get a variant's members as a Hash
:
Drink.water.attributes
# => {}
Drink.lemonade(Size.small).attributes
# => { size: #<variant Size::Small> }
Drink.coffee(Size.large, Temperataure.iced).attributes
# => { size: #<variant Size::Large> , temperature: #<variant Temperature::Iced> }
#to_h
Return the variant as a Hash
:
Drink.water.to_h
# => { water: {} }
Drink.lemonade(Size.small).to_h
# => { lemonade: { size: #<variant Size::Small> } }
Drink.coffee(Size.large, Temperataure.iced).to_h
# => { coffee: { size: #<variant Size::Large> , temperature: #<variant Temperature::Iced> } }
Use include_root: false
to make this method behave like #attributes
:
Drink.water.to_h(include_root: false)
# => {}
Drink.lemonade(Size.small).to_h(include_root: false)
# => { size: #<variant Size::Small> }
Drink.coffee(Size.large, Temperataure.iced).to_h(include_root: false)
# => { size: #<variant Size::Large>, temperature: #<variant Temperature::Iced> }
#members
Get a variant's members in the order they were in when passed into the initializer:
Drink.water.members
# => []
Drink.lemonade(Size.small).members
# => [#<variant Size::Small>]
Drink.coffee(Size.large, Temperataure.iced).members
# => [#<variant Size::Large>, #<variant Temperature::Iced>]
A Note on Mutability
All variants without members are memoized and frozen by default.
In our running example calling Size.small
, Size.large
, Temperature.hot
, Temperature.iced
, and Drink.water
would all return memoized and frozen objects, but Drink.lemonade(size)
and Drink.coffee(size, temperature)
would not.
This helps reduce the memory footprint of the gem, but makes it so that we cannot write to instance variables within the class.
Let's say that we wanted to memoize the result of #price_in_cents
like so:
Drink = SumsUp.define(:water, lemonade: :size, coffee: [:temperature, :size]) do
def price_in_cents
@price_in_cents ||= match do |m|
m.water 0
m.lemonade { |size| size.match(small: 350, large: 450) }
m.coffee { |size, _temperature| size.match(small: 295, large: 395) }
end
end
end
The Drink.lemonade
and Drink.coffee
variants would be unaffected because they are not frozen:
Drink.lemonade(Size.large).price_in_cents
# => 450
Drink.coffee(Size.small, Temperature.hot).price_in_cents
# => 295
However, Drink.water
will raise because it is frozen:
Drink.water.price_in_cents
# => RuntimeError: can't modify frozen Drink::Water
In general, it's better to find solutions which don't require state to be tracked within data types, but if mutability is absolutely required, we can work around this by passing memo: false
to the memberless variant's initializer:
Drink.water(memo: false).price_in_cents
# => 0
This will work with any memberless variant:
Size.small(memo: false)
# => #<variant Size::Small>
Size.large(memo: false)
# => #<variant Size::Large>
Temperature.hot(memo: false)
# => #<variant Temperature::Hot>
Temperature.iced(memo: false)
# => #<variant Temperature::Iced>
Maybes
SumsUp::Maybe
represents a value which may or may not be present.
Variants:
SumsUp::Maybe.nothing
# => #<variant SumsUp::Maybe::Nothing>
SumsUp::Maybe.just(1)
# => #<variant SumsUp::Maybe::Just value=1>
Predicates:
SumsUp::Maybe.nothing.nothing?
# => true
SumsUp::Maybe.nothing.just?
# => false
SumsUp::Maybe.just(1).nothing?
# => false
SumsUp::Maybe.just(2).just?
# => true
Pattern matching:
def maybe_to_int(maybe)
maybe.match do |m|
m.nothing 0
m.just { |num| num }
end
end
maybe_to_int(SumsUp::Maybe.nothing)
# => 0
maybe_to_int(SumsUp::Maybe.just(1))
# => 1
SumsUp::Maybe.of
builds a SumsUp::Maybe
from a value which may be nil
:
SumsUp::Maybe.of(nil)
# => #<variant SumsUp::Maybe::Nothing>
SumsUp::Maybe.of('cat')
# => #<variant SumsUp::Maybe::Just value="cat">
SumsUp::Maybe.of(false)
# => #<variant SumsUp::Maybe::Just value=false>
SumsUp::Maybe#chain
(and its alias, #flat_map
) can compose blocks which return SumsUp::Maybe
s, halting at the first SumsUp::Maybe.nothing
:
def double_if_odd(n)
SumsUp::Maybe
.just(n)
.chain { |x| x.odd? ? SumsUp::Maybe.just(x) : SumsUp::Maybe.nothing }
.chain { |x| SumsUp::Maybe.just(x * 2) }
end
double_if_odd(3)
# => #<variant SumsUp::Maybe::Just value=6>
double_if_odd(42)
# => #<variant SumsUp::Maybe::Nothing>
SumsUp::Maybe#map
applies a function to the value if it's present:
SumsUp::Maybe.nothing.map { |x| x + 1 }
# => #<variant SumsUp::Maybe::Nothing>
SumsUp::Maybe.just(3).map { |x| x + 1 }
# => #<variant SumsUp::Maybe::Just value=4>
SumsUp::Maybe#or_else
returns the wrapped value, or a default if it's not present:
SumsUp::Maybe.nothing.or_else(1)
# => 1
SumsUp::Maybe.nothing.or_else { 2 }
# => 2
SumsUp::Maybe.just(3).or_else(4)
# => 3
SumsUp::Maybe.just(4).or_else { 5 }
# => 4
Results
SumsUp::Result
represents a successful result or an error.
Variants:
SumsUp::Result.failure('update failed')
# => #<variant SumsUp::Maybe::Failure error="update failed">
SumsUp::Maybe.success('request payload')
# => #<variant SumsUp::Maybe::Just value="request payload">
Predicates:
SumsUp::Result.failure(false).failure?
# => true
SumsUp::Result.failure(0).success?
# => false
SumsUp::Result.success(true).failure?
# => false
SumsUp::Result.success(1).success?
# => true
Pattern matching:
def flip_result(result)
result.match do |m|
m.failure { |error| SumsUp::Result.success(error) }
m.success { |value| SumsUp::Result.failure(value) }
end
end
flip_result(SumsUp::Result.success('yay'))
# => #<variant SumsUp::Result::Failure error="yay">
flip_result(flip_result(SumsUp::Result.failure('boo')))
# => #<variant SumsUp::Result::Failure error="boo">
SumsUp::Result.from_block
converts a block which may raise into a SumsUp::Result
:
SumsUp::Result.from_block { raise 'unexpected error' }
# => #<variant SumsUp::Result::Failure error=#<RuntimeError: unexpected error>>
SumsUp::Result.from_block { 'good result' }
# => #<variant SumsUp::Result::Success value="good result">
SumsUp::Result.from_block(ArgumentError) { raise ArgumentError, 'bad argument' }
# => #<variant SumsUp::Result::Failure error=#<ArgumentError: Bad argument>
SumsUp::Result.from_block(ArgumentError) { raise KeyError, 'no such key' }
# => KeyError: no such key
SumsUp::Result#chain
(and its alias, #flat_map
) can compose blocks which return SumsUp::Result
s, halting at the first SumsUp::Result.failure(...)
:
User = Struct.new(:name, :email)
def parse_user(hash)
fetch_key(hash, :name).chain do |name|
fetch_key(hash, :email).chain do |email|
if URI::MailTo::EMAIL_REGEXP.match?(email)
SumsUp::Result.success(User.new(name, email))
else
SumsUp::Result.failure("invalid email: #{email}")
end
end
end
end
def fetch_key(hash, key)
if hash.key?(key)
SumsUp::Result.success(hash[key])
else
SumsUp::Result.failure("key not found: #{key}")
end
end
parse_user(email: '[email protected]')
# => #<variant SumsUp::Result::Failure error="key not found: name">
parse_user(name: 'Sam')
# => #<variant SumsUp::Result::Failure error="key not found: email">
parse_user(name: 'Sam', email: '[email protected]')
# => #<variant SumsUp::Result::Success value=#<struct User name="Sam", email="[email protected]">>
parse_user(name: 'Sam', email: '1337hacker')
# => #<variant SumsUp::Result::Failure error="invalid email: 1337hacker">
SumsUp::Result#map
applies a function to the successful values:
SumsUp::Result.failure('sorry kid').map { |x| x + ', nothing personal' }
# => #<variant SumsUp::Result::Failure error="sorry kid">
SumsUp::Result.success(10).map { |x| x * 2 }
# => #<variant SumsUp::Result::Success value=20>
SumsUp::Result#map_failure
applies a function to the failure errors:
SumsUp::Result.failure('sorry kid').map_failure { |x| x + ', nothing personal' }
# => #<variant SumsUp::Result::Failure error="sorry kid, nothing personal">
SumsUp::Result.success(10).map_failure { |x| x * 2 }
# => #<variant SumsUp::Result::Success value=10>
Why?
Some of these examples may seem odd if you're not familiar with sum types. If we were instead using the tools provided by Ruby, we might use a boolean to determine whether a given drink is a small or large, hot or cold. This would work, of course, so why use sum types?
Let's illustrate by defining OtherDrink
using a Struct
with booleans for is_hot
and is_large
OtherDrink = Struct.new(:type, :is_hot, :is_large) do
private_class_method(:new)
def self.water
new(:water, false, false)
end
def self.lemonade(is_large)
new(:lemonade, false, is_large)
end
def self.coffee(is_hot, is_large)
new(:coffee, is_hot, is_large)
end
end
OtherDrink.water
# => #<struct OtherDrink type=:water, is_hot=false, is_large=false>
OtherDrink.lemonade(true)
# => #<struct OtherDrink type=:lemonade, is_hot=false, is_large=true>
OtherDrink.coffee(true, false)
# => #<struct OtherDrink type=:water, is_hot=true, is_large=false>
OtherDrink
can do all of the things that Drink
can, but its API is less descriptive.
For example, to represent a small hot coffee using OtherDrink
, we would call OtherDrink.coffee(true, false)
.
In the sums_up
-defined Drink
, we'd instead call Drink.coffee(Size.small, Temperature.hot)
.
This may seem a bit contrived, but using a sum type instead of a boolean can help make our code more declarative and self-documenting.
Sum types can also provide extensibility when project requirements change.
If our example cafe started carrying medium coffees and lemonades, we only need update our Size
type to accomodate that:
Size = SumsUp.define(:small, :medium, :large)
Drink.coffee(Size.medium, Temperature.iced)
# => #<variant Drink::Coffee size=#<variant Size::Medium> temperature=#<variant Temperature::Iced>>
How would we handle this if we were using OtherDrink
?
A boolean is no longer suitable given that we need to track three different possible options, so we would probably end up using symbols like :small
, :medium
, and :large
.
This will work, but refactoring will likely be more difficult.
With Size
, after adding Size.medium
, we find our Size#match
calls, ensure that we're handling Size.medium
, and we're done.
With OtherDrink
's :small
, :medium
, and :large
symbols, we would need to refactor the code which uses OtherDrink.is_large
to instead match on symbols, and we would also introduce the possibility that a drink's size is invalid.
This can lead to us writing a lot of checks for invalid data which may or may not be necessary.
Sum types will do this for you; there's no way to make an invalid Size
, so we know that our #match
calls are categorically handling all cases.
Development
After checking out the repo, run bin/setup
to install dependencies.
Then, run bundle exec rake spec
to run the tests.
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 tags, and push the .gem
file to rubygems.org.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/nahiluhmot/sums_up. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.
License
The gem is available as open source under the terms of the MIT License.
Code of Conduct
Everyone interacting in the SumsUp projects codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.