OneRuby.devAN ENGINEERING NOTEBOOK

ruby · 5 min read

Ruby functional pipelines: make failure a value that stops the next step

Implement a small Ok/Err pipeline in Ruby. Test short-circuiting, map versus bind, false and nil values, and errors that should still raise.

A quote calculation has three ordinary outcomes: valid input, an invalid quantity, or an unknown product. Returning nil for both failures makes the next step guess what happened. Raising for every invalid form field moves routine branching into exception handlers.

There is a smaller option: return an explicit success or failure value, and give the pipeline one rule for continuing. The experiment here prices an order from an in-memory catalog. It uses no framework and does not attempt to become a general functional programming library.

Two values and one continuation rule

Ok carries a successful value. Err carries a symbolic reason. Both are Ruby Data records:

Ruby
Ok = Data.define(:value) do
def map
self.class.new(yield(value))
end
def bind
result = yield(value)
raise TypeError, "bind must return Ok or Err" unless result.is_a?(Ok) || result.is_a?(Err)
result
end
end
Err = Data.define(:code) do
def map = self
def bind = self
end

Map runs an ordinary transformation inside Ok and wraps its output. Bind expects the next stage to return another result. That distinction prevents accidental nesting: mapping a function that returns Ok gives Ok containing Ok; binding the same function gives the single returned Ok.

An Err returns itself from both methods without invoking the block. The value therefore carries the decision to stop. A later pricing function cannot accidentally run after parsing failed unless code explicitly unwraps or bypasses the protocol.

Bind checks its result type. A forgotten wrapper becomes a TypeError at the composition boundary instead of an unexplained missing-method error farther down the application. This is a runtime convention, not a static type system. Ruby can still call these methods with the wrong kinds of values.

Define the input policy precisely

The parser accepts a Hash containing a lowercase SKU and a decimal quantity string. Quantity must be one to three digits, with no leading zero, sign or whitespace; the permitted range is therefore 1–999. These are choices for this example, not universal commerce rules.

Checking the shape before conversion matters. A conversion such as to_i would turn some invalid strings into plausible numbers. Here, "2x" is rejected, and the valid "2" becomes the integer two. A malformed Hash shape, invalid SKU and invalid quantity receive different error codes.

The SKU is copied and frozen before the Request record retains it. The caller can subsequently change its input string without changing an already prepared request. Data protects member assignment, but does not deep-freeze its members. The test suite demonstrates that limitation with a separate Ok containing a mutable array.

Compose the business calculation

The catalog maps SKUs to nonnegative prices in integer cents. The entire successful path is:

Ruby
def call(raw, catalog:)
parse(raw).bind { |request| price(request, catalog) }
end

An unknown SKU becomes Err because a user can reasonably request a product that is no longer available. A catalog price that is nil or negative raises TypeError. That is a violated internal contract, not a normal customer validation result.

This separation is a useful review question: which failures can callers act on, and which indicate that the program or its configuration is broken? A broad rescue returning Err for everything would hide bugs in the latter group. The example deliberately lets them reach the test runner.

The resulting Quote holds SKU, quantity and total cents. It has no method for charging a card or reserving inventory. Those effects would need additional guarantees; placing them in a bind block would not make them transactional or reversible.

False is not a failure

Truthiness is a tempting shortcut for result handling. It is also the wrong contract here. Ok containing false is still a successful calculation of a boolean. Ok containing nil is still distinguishable from Err.

Two tests exercise those values, and another confirms that bind continues from Ok(false). This distinction becomes useful when a pipeline computes feature flags, optional metadata or a negative answer to a valid question. Failure should be represented by the variant, not inferred from its payload.

The public formatter uses pattern matching to turn a successful Quote into a total message or an Err into a rejection message. A different Ok payload raises rather than pretending it is a valid quote. The application edge owns that presentation choice; the calculation does not print or log.

Test that later work did not happen

It is easy to test only that invalid input returns an error. The more important assertion is that the next stage was never called. The suite sets a flag inside a bind block after invalid parsing and verifies that it remains false.

That little bit of instrumentation is a test side effect. It does not make the implementation a side-effecting pipeline, and it does not prove that arbitrary functions passed to bind are pure. The abstraction controls continuation; the supplied functions still determine behavior.

The tests also check unknown products, the quantity boundaries, a changed caller-owned string, a zero-price product and a malformed catalog entry. The zero-price case is especially useful because it rejects another common truthiness shortcut: zero is a legitimate price under the stated policy.

Run it and decide whether the abstraction earns its place

Download the complete example. It activates Minitest 6.0.6 and was executed with Ruby 3.3.2. If needed, install that exact Minitest version into the Ruby environment you will use.

Terminal
ruby example.rb --seed 42

The local run passed 9 tests and 31 assertions. No HTTP request, database or external catalog was involved.

For one validation and one calculation, explicit conditionals may be clearer. This result protocol becomes useful when several stages share the same continuation rule and error representation. It intentionally returns the first failure; collecting every form error would require a different operation. Start with the smaller collection pipeline when the data is already valid, and add a result type only when failure propagation is the complexity you actually need to manage.

Found a mistake or tried a different approach?

Send Alex a note ↗