Kumi: Calculation Logic in Ruby That Also Runs in JavaScript
Many of my projects are webshops and billing systems. The same problem comes up in every one of them: the price logic lives on the server, but the customer wants to see the total in the browser while they change quantities. So the rules get written twice, once in Ruby and once in JavaScript, and sooner or later the two versions disagree.
Kumi by André Muta is a Ruby gem that takes this problem seriously. It is a declarative DSL for calculation logic, such as tax rules, pricing and scoring, that compiles to plain Ruby and plain JavaScript from one schema. I have been trying it out, and I really like what I see.
What Kumi does
You describe the shape of your input data and the values you want computed. You don’t write loops or decide the evaluation order. The compiler works out the dependencies, checks types and generates straight-line code without dependencies for each target.
Two ideas make it different from writing plain Ruby methods:
- Broadcasting from the data shape. If an input is an array of items, an expression like
quantity * unit_priceis automatically computed per item. A function likefn(:sum, ...)collapses it back to a single value. This also works through nested arrays. - Checks when the schema is defined. Type errors, dependency cycles and conditions that can never be true are reported when the schema loads, not when a customer hits the code path in production.
Install it with:
gem install kumi
It needs Ruby 3.1 or newer and is MIT licensed. The current version is 0.1.0 from June 2026, and the README describes the project as experimental: the public API may still change.
An example: a Swiss invoice
To try it, I wrote a small invoice. Items with a quantity of 10 or more get a 10% bulk discount, Swiss VAT of 8.1% is added, and the total is rounded to 5 Rappen, as usual for cash amounts in Switzerland.
require "kumi"
module Invoice
extend Kumi::Schema
schema do
input do
array :items do
hash :item do
integer :quantity
decimal :unit_price
end
end
end
trait :bulk, input.items.item.quantity >= 10
let :gross, input.items.item.quantity * input.items.item.unit_price
value :line_totals, select(bulk, gross * 0.9, gross)
value :subtotal, fn(:sum, line_totals)
value :vat, subtotal * 0.081
# no round() yet: scale to 5 Rappen, add 0.5, truncate
value :total, fn(:to_integer, (subtotal + vat) * 20 + 0.5) / 20.0
end
end
result = Invoice.from(items: [
{ quantity: 2, unit_price: 50.0 },
{ quantity: 12, unit_price: 10.0 }
])
result[:line_totals] # => [100.0, 108.0]
result[:subtotal] # => 208.0
result[:vat] # => 16.848
result[:total] # => 224.85
A few things to notice:
traitdeclares a boolean condition. Because it refers toinput.items.item.quantity, it is evaluated per item.letis an intermediate value,valueis an output you can read from the result.select(condition, if_true, if_false)picks per item. For more cases there is avalue ... do on ... base ... endcascade.- There is no loop anywhere, but
line_totalsis an array with one entry per item, andsubtotalis a single number.
The same logic in JavaScript
This is the part that interests me most for webshops. One call writes a JavaScript module:
Invoice.write_source("invoice.mjs", platform: :javascript)
Every value, let and trait becomes an exported function. This is the generated _total, unchanged:
export function _total(input) {
let t88 = input["items"];
let acc95 = 0;
for (let items_i90 = 0; items_i90 < t88.length; items_i90++) {
let items_el89 = t88[items_i90];
let t91 = items_el89["quantity"];
let t92 = 10;
let t41 = t91 >= t92;
let t93 = items_el89["unit_price"];
let t48 = t91 * t93;
let t94 = 0.9;
let t51 = t48 * t94;
let t59 = t41 ? t51 : t48;
acc95 += t59;
}
let t96 = 0.081;
let t87 = acc95 * t96;
let t28 = acc95 + t87;
let t97 = 20;
let t30 = t28 * t97;
let t98 = 0.5;
let t32 = t30 + t98;
let t33 = __core_to_integer(t32);
let t99 = 20.0;
let t35 = t33 / t99;
return t35;
}
This is exactly what I want from generated code: a single loop that does the discount, the sum, the VAT and the rounding, with no library and no interpreter behind it. You can read it, step through it in the debugger and ship it as it is. I ran the module with Node.js on the same input and got exactly the same results as in Ruby: [100, 108], 208, 16.848 and 224.85.
Kumi in ruby.wasm
I work a lot with ruby.wasm, for example for Ruby Koans in the browser, so I wanted to know whether Kumi itself also runs inside Ruby compiled to WebAssembly. It does, with a few lines of setup:
# before require "kumi"
ENV["KUMI_PASS_BUDGET_MS"] = "0" # no threads in WebAssembly: no compile time limit
require "tmpdir"
Dir.mkdir("/tmp") unless Dir.exist?("/tmp")
def Dir.tmpdir = "/tmp" # a place for Kumi's code cache
require "kumi"
Kumi guards each compiler pass with a time limit based on Timeout, which starts a thread, and ruby.wasm has no threads. The environment variable switches the limit off. Kumi also writes the generated Ruby code to a cache directory before loading it, and in the browser’s in-memory file system Ruby doesn’t find a temporary directory on its own.
With this setup, the invoice example above gives exactly the same results in ruby.wasm, down to the 224.85. I tested it with ruby.wasm 2.10.1 (Ruby 4.0) in Node.js, using the same in-memory file system that ruby.wasm uses in the browser. Kumi is pure Ruby, and its dependencies zeitwerk and mutex_m load without any changes.
So you have two ways to bring your rules to the browser: export them to JavaScript, which is the lightest option, or, if your frontend already runs Ruby in WebAssembly, define and run Kumi schemas right there.
Mistakes are caught early
Kumi analyzes conditions when the schema is defined. If you declare that an age is between 0 and 150 and then write a condition that can never be true, loading the schema fails:
schema do
input { integer :age, domain: 0..150 }
trait :impossible_age, input.age == 200
end
# Kumi::Core::Errors::SemanticError: conjunction `impossible_age` is impossible
In business rules, a condition like this is usually a typo or a misunderstanding of the requirements. Kumi tells you the moment the code loads, instead of you finding out months later that a discount never applied. That alone makes it worth a look.
Composing rules
Schemas can import other schemas with import :tax, from: TaxPolicy. A rule that only knows about a single amount can then be applied to every item of an order, and the compiler inlines it into the generated code. The schema imports documentation shows how this works.
Good to know
- Young and moving fast. Kumi is at version 0.1.0 and the README marks the API as experimental, so pin the version in your Gemfile. The changelog shows steady progress: recent releases brought a new compiler pipeline with loop fusion and a rewritten parser with clear error messages.
- Rounding. There is no
roundfunction yet, but theto_integertrick above does the job for positive amounts and behaves the same in Ruby and JavaScript. - Money. The results are floating point numbers, so for prices I round at the end, as in the example.
Try it in the browser
The quickest way to get a feeling for Kumi is the interactive playground. It has a language introduction and larger examples such as a US tax calculator and a payroll run over 10,000 employees.
Kumi fits perfectly wherever the same calculation has to run on the server and in the browser: price calculators, offer configurators, shipping costs. One schema, two languages, identical results. I’m looking forward to using it in my own projects and to seeing where it goes next.