Skip to content

Cookbook

Recipes that combine turo’s features into real calculations. Each one starts with a problem, shows the turo source, and gives the output the turo CLI actually produces with the standard prelude. Read REFERENCE.md first for the individual features; this document is about putting them together.

Two display conventions below:

  • Inline turo examples are live: the editor shows each line’s computed answer beside it, so there’s no separate echo comment.
  • A results block shows the per-line output you get from turo --json (the notepad surface), one row per line.

A formula is a system of equations, and turo solves it in whichever direction you leave a hole. Write the monthly-payment relation once, and you can compute the payment from the principal — or the principal from a payment you can afford.

formula Mortgage(principal, monthly rate, n, payment):
  payment = principal * monthly rate / (1 - (1 + monthly rate) ^ (0 - n))

// Forward: what's the payment on a $250k loan at 6% over 30 years?
Mortgage(principal = 250000, monthly rate = 6% / 12, n = 360)::payment as 2 dp

// Backward: $1,500/month supports how much principal? Same formula.
Mortgage(payment = 1500, monthly rate = 6% / 12, n = 360)::principal as 2 dp

This pulls together four features: a formula with named properties, percent arithmetic for the rate, exponentiation in the body, and the solver rearranging the equation to isolate whichever property you ask for with ::. The as 2 dp rounds the result; the thousands separator comes from the prelude’s default.


In --json mode turo evaluates one line at a time and returns a result for each — a calculating notepad. Bindings can reference earlier ones in any order.

income = 4200
rent = 1350
groceries = 600
savings rate = 15%
saved = income * savings rate
left over = income - rent - groceries - saved

results:

income = 4200
rent = 1350
groceries = 600
savings rate = 15%
saved = 630
left over = 1620

The 15% flows through income * savings rate as ordinary multiplication, and left over composes three earlier bindings. Change income and every dependent row recomputes.


Compound time units (3 hour 45 minute) and compound unit division (minutes/km) turn a finish time into a pace, then project it onto a different distance.

marathon = 42.195 km
finish = 3 hour 45 minute
pace = finish / marathon to minutes/km
ten k time = pace * 10 km to minutes

results:

marathon = 42.195 km
finish = 3.75 hour
pace = 5.33238535371 minutes/km
ten k time = 53.3238535371 minutes

finish / marathon is a Time / Length, which turo recognizes as the inverse of a speed; converting it to minutes/km gives a readable pace. Multiplying that pace by a new distance cancels the length dimension back out to a time.


Stacked discounts, tips, and splitting the bill

Section titled “Stacked discounts, tips, and splitting the bill”

Percentages chain the way a shopper expects, and combine with a unit so a bill keeps its currency.

// 20% off, then a further 10% off — not 30% off.
let list price = 120
list price - 20% - 10%

Splitting a tab needs a currency. The standard prelude has no money dimension — that is deliberate, since money is not physics — so declare your own:

dimension Money
unit USD of Money, also: dollars

let bill = 84 USD
let total = bill + 18%        // add an 18% tip
total / 4 as 2 dp

bill + 18% keeps the USD unit because percentage arithmetic is dimension-transparent, and dividing by 4 (a plain number) leaves the unit intact.


Extend the money idea into several currencies anchored to one base, and turo will convert between them like any other units.

dimension Money
unit USD of Money, also: dollars
unit EUR = 1.09 USD, also: euros
unit GBP = 1.27 USD, also: pounds

hotel = 220 EUR
flight = 180 GBP
trip = (hotel to USD) + (flight to USD)

results:

hotel = 220 EUR
flight = 180 GBP
trip = 468.4 USD

One gotcha worth knowing: to binds looser than +, so hotel to USD + flight to USD parses as hotel to (USD + flight) to USD and fails. Parenthesize each conversion. The parser’s precedence is the prelude’s choice — see prelude/format.turo.


A formula body can bind a sub-instance — a property that is itself another formula. Its inner names join the outer equation system under a path prefix, so turo solves the merged system as a unit. That means a back-solve reaches straight through the composition: supply the outer result, leave an inner property free, and turo finds it.

formula Rect(w, h):
  area = w * h

formula Box(height, volume):
  base = Rect(h = 3 m)             // a sub-instance — Rect lives inside Box
  volume = base::area * height

// Know the box's volume and height, and the base's height — solve the base's width.
Box(height = 4 m, volume = 24 m^3)::base::w

turo flattens this to base::area = base::w * 3 m, volume = base::area * 4 m, and volume = 24 m^3, then isolates base::w two levels down. Run it forward instead — Box(height = 4 m) with base = Rect(w = 2 m, h = 3 m) — and the result reads back as a nested record: Box(height = 4 m, base = Rect(w = 2 m, h = 3 m, area = 6 m^2), volume = 24 m^3).

A single formula back-solves just as readily. Here we know a cylinder’s volume and height and ask for the radius — turo isolates r through the square:

formula Cylinder(r, h, volume):
  volume = 3.14159265 * r ^ 2 * h

Cylinder(volume = 1 l, h = 10 cm)::r to mm

The volume 1 l and height 10 cm are different units of different dimensions; turo normalizes both to base units before solving, so the radius comes out correctly dimensioned and converts cleanly to millimetres.


suvat is the classic motion system: five quantities, two equations. Declare it once and solve for whichever one you leave out.

formula Suvat(s, u, v, a, t):
  v = u + a * t
  s = u * t + a * t ^ 2 / 2

// A car covers 100 m from rest in 5 s — what was its acceleration?
Suvat(s = 100 m, u = 0 m/s, t = 5 s)::a

This is a genuine two-equation, multi-unknown system. turo substitutes the knowns, recognizes that a can be isolated from the distance equation once u and t are fixed, and solves it — carrying m, s, and the derived m/s^2 through the whole rearrangement.


@-literals are real instants, so date arithmetic respects the calendar, and zone conversion respects offsets and daylight saving — both via jiff, not hand-rolled.

let christmas = @2026-12-25
christmas - today to days

Subtracting two instants gives a duration in seconds; converting it to days makes it readable. (today comes from the host clock; the output above is for 2026-06-26.)

Planning a call between offices is a zone conversion:

@2026-06-26 at @17:00 in Europe/London to America/New_York

in Europe/London fixes the wall-clock time to a zone; to America/New_York converts the same instant into the other zone, doing the offset arithmetic for you.


Embedding: a units-aware evaluator in Rust

Section titled “Embedding: a units-aware evaluator in Rust”

To put turo behind your own interface, wire the standard prelude into a ScopeResolver once and vend a fresh document per evaluation. This is the same wiring the turo CLI uses.

use std::collections::BTreeMap;
use turo_lang::{ImportResolver, InMemoryResolver, ScopeResolver};
/// Build an evaluator with the full standard prelude loaded.
fn standard_resolver() -> ScopeResolver {
let mut chunks = BTreeMap::new();
for entry in std::fs::read_dir("prelude").unwrap().flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("turo") {
let stem = path.file_stem().unwrap().to_str().unwrap().to_string();
chunks.insert(stem, std::fs::read_to_string(&path).unwrap());
}
}
let prelude: Box<dyn ImportResolver> = Box::new(InMemoryResolver::from_map(chunks));
let user: Box<dyn ImportResolver> = Box::new(InMemoryResolver::empty());
ScopeResolver::new(prelude, user, Some("standard"))
}
/// Evaluate one line and render its result the way the CLI does.
fn eval_one(resolver: &ScopeResolver, src: &str) -> String {
let mut doc = resolver.new_document();
let result = doc.evaluate_document(src);
if let Some(row) = result.results.first() {
if let Some((numeric, unit)) = row.display_strings() {
return if unit.is_empty() { numeric.to_string() } else { format!("{numeric} {unit}") };
}
}
result.errors.first()
.map(|e| format!("error: {}", e.variant_name()))
.unwrap_or_default()
}
fn main() {
let resolver = standard_resolver();
for line in ["60 mph to kph", "solve for x: x ^ 2 = 144", "5 m + 3 kg"] {
println!("{line:<24} => {}", eval_one(&resolver, line));
}
}

Output:

60 mph to kph => 96.56064 kph
solve for x: x ^ 2 = 144 => 12
5 m + 3 kg => error: DimensionMismatch

display_strings() gives the rendered numeric and unit parts separately, so your UI can lay them out however it likes. For the browser, the turo-wasm crate wraps this same engine — it embeds the prelude with include_str! and exposes evaluate/apply_change returning the JSON envelope, which the Svelte notepad in web/ renders live as you type.


These recipes only scratch what the combination of dimensions, formulas, and the solver makes possible. Two directions to explore:

  • Declare your own surface in a prelude. A host prelude can add operators, native functions, inverse rules, and unit groups — the machinery the standard prelude is built from. See DESIGN.md for how dispatch and the solver consume those declarations.
  • Push the solver. Multi-equation formulas with units, guards, and composition are where turo earns its keep. When a shape stalls, the error tells you whether you under-specified it (InsufficientBindings) or hit a current engine limit (EngineCapabilityGap).