SorbetTyped::ShortCircuit
Fully sorbet typed implementation of a fast-fail pattern based on signaling. It allows to chain multiple step-methods
which each could fail and abort the whole process while keeping return values flexible to communicate eventual errors or
further use some result. It should prevent if...else patterns after every part of a bigger process to ensure a step
worked or bubble up failure message if not.
Using this, you can break down more complex processes into smaller steps and make methods smaller and more readable while keeping full type safety.
Inspiration for this comes from dry-monads. The focus lies on strict type-safety while iteratively calling different steps of a bigger process and not relying on any instance-state or custom DSL.
Installation
Install the gem and add to the application's Gemfile by executing:
bundle add sorbet_typed-short_circuit
If bundler is not being used to manage dependencies, install the gem by executing:
gem install sorbet_typed-short_circuit
Usage
The following examples demonstrate the basics of how you can use this gem. For a detailed look at what you can and cannot do, you could look at the type checker specs and usage specs.
Basic Usage
Initialize a new circuit with defined Success- and Failure-Type and call the run method. This method takes a block which
gets a CircuitBreaker as parameter. The circuit breaker stops the block execution early, if it receives a Shorted
signal.
circuit = SorbetTyped::ShortCircuit[
Symbol, # this will be the return type, when the block executes successfully
T::Array[String] # this will be the type of the Signal-Payload, when the block exits early
].new
result = circuit.run do |circuit_breaker|
foo = circuit_breaker.(get_foo) # breaks the block, if `get_foo` returns a shorted signal. Otherwise returns a predictable success type.
= circuit_breaker.((foo:)) # breaks the block, if `do_bar` returns a shorted signal.
transform_baz(bar:) # must return the success type or a shorted signal. CircuitBreaker not needed, because it is the last block statement
end
T.reveal_type(result) # => T.any(Symbol, SorbetTyped::ShortCircuit::Signals::Shorted[T::Array[String]])
if result.is_a? SorbetTyped::ShortCircuit::Signals::Shorted
T.reveal_type(result.payload) # => T::Array[String]
else
T.reveal_type(result) # => Symbol
end
Step Method Signature
For step methods to be useful for the circuit breaker, they should return a union of any type you want to return and a shorted signal corresponding to the circuits failure type. Passing a signal type with unfitting payload type, will result in a type error.
extend T::Sig
sig { returns(T.any(String, SorbetTyped::ShortCircuit::Signals::Shorted[T::Array[String]])) }
def get_foo
# ...
end
Signaling a failure
You can generate a shorting signal using the ::short method of the SorbetTyped::ShortCircuit class. Passing this
signal to a circuit breaker will breaker the corresponding circuit. The payload type of the signal will be automatically
inferred from the passed parameter.
extend T::Sig
sig { params(foo: String).returns(T.any(String, SorbetTyped::ShortCircuit::Signals::Shorted[T::Array[String]])) }
def (foo:)
if foo == 'foo'
return 'bar'
else
SorbetTyped::ShortCircuit.short(['Unexpected value for foo:', foo])
end
end
Circuit Interface
After initializing a circuit, it will respond to the following methods:
#run=> Run a number of methods and use the circuit breaker to exit early. Returns either a successful value or the shorted signal#run_without_signal=> Like run, but already extracts the signal payload on failure. Useful, if you don't want to use the return value for flow-control. E.g. when success and failure use the same type, like a custom result-object that gets filled with different information depending on the outcome.#last_run_short_circuited?=>trueorfalse, depending on the outcome of the last run. Not really useful with sorbet, as you cannot use it for flow control. But it's there.
Passing Circuit Breakers as Method Parameters
Because the gems implementation relies heavily on generics and uses a raise/rescue pattern to implement the early exit,
there are few edge-cases where directly using internal classes like the CircuitBreaker would allow to return different
types than originally defined. This would introduce bugs, as static typing is circumvented and runtime type checks catch
them pretty late, as generics get removed from runtime checks.
To circumvent this, the custom error and the circuit breaker class are private constants, making accidental access harder.
If you want to build some abstractions around the short circuit class, that need the circuit breaker to be passed around
to some method, you cannot use its private class within the type signature. Instead, use
SorbetTyped::ShortCircuit::CircuitBreakerType, which is a module without any behavior, but the circuit breaker
fulfills it and can be passed as parameter to methods accepting this type.
extend T::Sig
sig { params(circuit_breaker: SorbetTyped::ShortCircuit::CircuitBreakerType[String]).returns(T.any(NilClass, SorbetTyped::ShortCircuit::Signals::Shorted[String])) }
def foo(circuit_breaker:)
circuit_breaker.([SorbetTyped::ShortCircuit.short('shorted'), nil].sample)
end
SorbetTyped::ShortCircuit[NilClass, String].new.run do |circuit_breaker|
foo(circuit_breaker:)
end
Nesting Circuits
It is possible to nest circuits. Especially when splitting your logic over multiple methods, each of them might use a circuit themselves to portray a part of the process. When doing this, each circuit creates its own circuit breaker, which can only break its original circuit. This is to prevent accidentally breaking inner circuits using outer circuit breakers, leading to incorrect types being returned.
SorbetTyped::ShortCircuit[String, Symbol].new.run do |outer_circuit_breaker|
SorbetTyped::ShortCircuit[Integer, NilClass].new.run do |inner_circuit_breaker|
outer_circuit_breaker.(SorbetTyped::ShortCircuit.short(:failure)) # shorted signal must fulfill failure type of the outer circuit
# this will never be reached, as the breaker always gets a shorted signal.
# Sorbet detects this as unreachable
'foo'
end
# this will never be reached, as the outer breaker used within the inner
# circuit will still break the outer circuit. But sorbet cannot statically
# detect this as unreachable.
'bar'
end
Development
The project uses mise-en-place as development tool.
After checking out the repo, run mise run setup to install dependencies. Then, run mise test to run the tests. You
can also run mise task ls for a list of available tasks.
RSpec is used as test suite. Spec files can and should be placed right beside their associated class files.
Use the command mise run console (or ./bin/console) to start an interactive ruby console session with the gem
already loaded.
To install this gem onto your local machine, run bundle exec rake install. To release a new version, run
mise run deploy, which will update the version number, create a release commit, tag it and push the built gem to
rubygems.org.
Contributing
Bug reports and merge requests are welcome on GitLab at gitlab.com/sorbet_typed/short_circuit.