Sum types in plain Ruby, checked at Rails boot. The repo behind the Ruby Melbourne talk "SumTypes and Ruby" (August 2026).
SumType.matchervalidates its handlers against the variant registry when the matcher is constructed, not when it is called.- Matchers are constants, so construction happens when the file loads.
- Rails eager-loads every constant at boot, in production and CI.
Add a variant, forget to update a matcher, and the app refuses to start, naming the missing variant and the file that forgot it.
bundle install
rake # rubocop + tests + boot checkA minimal Rails app: railties and activemodel only, no ActiveRecord, no
database. One domain (a job's status), one mistake (a variant is added, the
code matching on it is not), two outcomes.
PlainJobStatusOps is idiomatic case/in with no else, and has no case for
JobStatus::Failed. Nothing noticed.
bin/plain-crashThe app boots, then the first failed job dies with NoMatchingPatternError at
the call site. No check ran; a value found the hole.
In app/models/job_status.rb:
variants(
queued: Queued,
running: Running,
done: Done,
# failed: Failed, <-- uncomment this line
)JobStatusOps is left alone, exactly as it would be if you forgot it.
bin/boot-checkapp/models/concerns/sum_type.rb:33:in 'SumType#matcher': missing handlers for: failed (SumType::NonExhaustiveMatch)
from app/models/job_status_ops.rb:5:in '<module:JobStatusOps>'
BOOT FAILED (exit 1) — a matcher does not cover its sum.
RuboCop runs at full strictness (NewCops: enable) plus two custom cops in
lib/rubocop/cop/sum_type/, both guarding preconditions the boot check needs.
SumType/MatcherInMethod catches a matcher built inside a method, which is
validated on first call rather than at boot. This is the load-bearing one: the
guarantee holds only if every matcher is a constant.
SumType/VariantsNotDeclared catches a module that extends SumType but never
calls variants(...), which raises on first use rather than at boot.
config.eager_load = truein development is deliberate, sobin/railsbehaves the way production and CI already do.- Dispatch is a class-keyed hash with an
is_a?scan as the fallback for subclass carriers, so it costs less thancase/in.bin/benchmeasures it. test/guards that fallback: delete the scan as redundant andtest_falls_back_to_is_a_scan_for_subclassesis the only thing that notices. The boot check cannot see it, since it only proves matchers are exhaustive.- A cop doing the exhaustiveness check itself was prototyped and rejected. It
works, but must abstain on
**splathandlers, aliased sums and dynamic keys, all of which the boot check catches.