hospitality.recipe-scale
Scale a recipe to a new number of portions, tidying metric units (g to kg, mL to L) and rounding to kitchen precision.
1.0.0 (not the latest) · published 2026-10-03 by charlie · Anterra
Pinned by 25 tests, run in TypeScript, Python and Rust.
What it does
Scales a recipe from the number of portions it makes to the number wanted, and writes each quantity the way a kitchen would weigh it.
## The rules
For example
scaleRecipe(ingredients ×5, 4, 6)→ ×5 4 to 6 portions: grams, millilitres, eggs, a pinch-sized amount and spoonsscaleRecipe(ingredients ×3, 4, 10)→ ×3 4 to 10: grams over 1000 become kilograms, millilitres become litresscaleRecipe(ingredients ×2, 4, 2)→ ×2 4 to 2: kilograms under 1 become grams, litres become millilitres
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
export function scaleRecipe(ingredients: readonly RecipeQuantity[], fromPortions: number, toPortions: number): readonly RecipeQuantity[]
| ingredients | RecipeQuantity[] | the recipe as written; [] gives [] |
| fromPortions | int | the portions the recipe makes, 1 to 10000 |
| toPortions | int | the portions wanted, 1 to 10000 |
| returns | RecipeQuantity[] | the same ingredients, in order, scaled and rounded |
The type it declares, generated into your project
/** One ingredient and how much of it. */
export interface RecipeQuantity {
readonly name: string;
/** decimal text such as "250" or "0.5" */
readonly quantity: string;
/** mg, g, kg, mL and L are tidied; each is rounded up; anything else keeps its unit */
readonly unit: string;
}
Your code names it in one line, in the file that uses it
import { scaleRecipe } from "#fune/hospitality.recipe-scale@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { convertUnits } from "./units_convert.ts"; ← from units.convert ^1.0.0 · built alongside by fune
import { type RecipeQuantity } from "./hospitality_recipe_scale_types.ts";
const DECIMAL = /^[0-9]+(\.[0-9]+)?$/;
const METRIC: Record<string, [string, string]> = {
mg: ["g", "kg"],
g: ["g", "kg"],
kg: ["g", "kg"],
mL: ["mL", "L"],
L: ["mL", "L"],
};
function parseDecimal(text: string): [bigint, bigint] {
const [whole, fraction = ""] = text.split(".");
return [BigInt(whole + fraction), 10n ** BigInt(fraction.length)];
}
function halfUp(numerator: bigint, denominator: bigint): bigint {
return (numerator * 2n + denominator) / (denominator * 2n);
}
function formatScaled(value: bigint, decimals: number): string {
const unit = 10n ** BigInt(decimals);
const whole = (value / unit).toString();
if (decimals === 0) return whole;
const fraction = (value % unit).toString().padStart(decimals, "0").replace(/0+$/, "");
return fraction === "" ? whole : `${whole}.${fraction}`;
}
function checkPortions(name: string, value: number): void {
if (!Number.isInteger(value) || value < 1 || value > 10000) {
throw new RangeError(`${name} must be a whole number from 1 to 10000, received ${value}`);
}
}
/**
* Scale a recipe from one number of portions to another.
*
* The scaling is exact (decimal text times a fraction), and each quantity is
* rounded once, to the precision a kitchen weighs to: whole grams or
* millilitres from 10 up, one decimal place from 1 to 10, two below 1.
* Metric amounts move between g and kg, mL and L, at 1000. Counted items
* (each) round up, since half an egg short is short. Any other unit keeps its
* name and is rounded to two decimal places.
*/
export function scaleRecipe(ingredients: readonly RecipeQuantity[], fromPortions: number, toPortions: number): readonly RecipeQuantity[] {
checkPortions("fromPortions", fromPortions);
checkPortions("toPortions", toPortions);
const to = BigInt(toPortions);
const from = BigInt(fromPortions);
return ingredients.map(({ name, quantity, unit }) => {
if (typeof quantity !== "string" || !DECIMAL.test(quantity)) {
throw new RangeError(`quantity must be a non-negative decimal like "12.5", received "${quantity}" for "${name}"`);
}
const [whole, fraction = ""] = quantity.split(".");
const trimmedFraction = fraction.replace(/0+$/, "");
if ((whole + trimmedFraction).replace(/^0+/, "").length > 15 || trimmedFraction.length > 15) {
throw new RangeError(`quantity "${quantity}" for "${name}" has too many digits: at most 15 significant digits and 15 decimal places`);
}
if (unit === "") throw new RangeError(`unit must not be empty for "${name}"`);
const metric = METRIC[unit];
if (metric !== undefined) {
const [small, large] = metric;
const [n, scale] = parseDecimal(convertUnits(quantity, unit, small, 12));
const num = n * to;
const den = scale * from;
const decimals = num < den ? 2 : num < den * 10n ? 1 : 0;
const rounded = halfUp(num * 10n ** BigInt(decimals), den);
if (decimals === 0 && rounded >= 1000n) {
return { name, quantity: convertUnits(rounded.toString(), small, large, 3), unit: large };
}
return { name, quantity: formatScaled(rounded, decimals), unit: small };
}
// Trailing zeros dropped, so "1.000000000000000000000" stays small.
const [n, scale] = parseDecimal(trimmedFraction === "" ? whole : `${whole}.${trimmedFraction}`);
const num = n * to;
const den = scale * from;
if (unit === "each") {
return { name, quantity: ((num + den - 1n) / den).toString(), unit };
}
return { name, quantity: formatScaled(halfUp(num * 100n, den), 2), unit };
});
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the TypeScript package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add hospitality.recipe-scale
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./hospitality.recipe-scale-1.0.0-typescript.fune, or fetch it from a terminal with fune pull hospitality.recipe-scale@1.0.0:typescript.
The whole function, every language, is one file too: hospitality.recipe-scale-1.0.0.fune, 25,056 bytes, sha256 395e9476e9dc68534331a5c2b26904d45050e60834242409350f723763cd9f93. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before hospitality.recipe-scale
after — your function gets the result and the arguments, and returns the final result.
// fune: after hospitality.recipe-scale
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
// fune: replace units.convert in hospitality.recipe-scale
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show hospitality.recipe-scale --steps.
// fune: step hospitality.recipe-scale after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| 4 to 6 portions: grams, millilitres, eggs, a pinch-sized amount and spoons | ingredients ×5, 4, 6 | → | ×5 |
| 4 to 10: grams over 1000 become kilograms, millilitres become litres | ingredients ×3, 4, 10 | → | ×3 |
| 4 to 2: kilograms under 1 become grams, litres become millilitres | ingredients ×2, 4, 2 | → | ×2 |
| 999.6 g rounds to a whole 1000 g and is written as 1 kg | ingredients ×1, 2, 3 | → | ×1 |
| under a gram keeps two decimal places | ingredients ×1, 4, 6 | → | ×1 |
| a third: whole grams from 10, one place from 1 to 10, two places for other units | ingredients ×3, 3, 1 | → | ×3 |
| a half rounds up, not to even: 2.25 g is 2.3 g | ingredients ×1, 4, 9 | → | ×1 |
| milligrams are tidied into grams | ingredients ×1, 1, 4 | → | ×1 |
| a small kilogram amount is written in grams | ingredients ×1, 1, 1 | → | ×1 |
| eggs that divide exactly stay exact | ingredients ×1, 4, 6 | → | ×1 |
Show the other 15 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| eggs always round up: 3 for 4 is 3.75 for 5, so 4 | ingredients ×1, 4, 5 | → | ×1 |
| nothing scales to nothing | ingredients ×1, 4, 6 | → | ×1 |
| imperial units keep their unit, to two places | ingredients ×2, 3, 4 | → | ×2 |
| an unrecognised unit is scaled as written | ingredients ×1, 4, 6 | → | ×1 |
| the same portions still tidies the unit | ingredients ×1, 4, 4 | → | ×1 |
| a banquet: 5 kg for one hundred times the portions | ingredients ×1, 1, 100 | → | ×1 |
| a few millilitres keep one place | ingredients ×1, 2, 3 | → | ×1 |
| trailing zeros are fine | ingredients ×1, 1, 2 | → | ×1 |
| an empty recipe scales to an empty recipe | , 2, 4 | → | |
| zero portions is an error | ingredients ×1, 0, 4 | → | error: fromPortions must be a whole number from 1 to 10000 |
| too many portions is an error | ingredients ×1, 4, 10,001 | → | error: toPortions must be a whole number from 1 to 10000 |
| a fraction as text is an error | ingredients ×1, 4, 6 | → | error: quantity must be a non-negative decimal |
| a negative quantity is an error | ingredients ×1, 4, 6 | → | error: quantity must be a non-negative decimal |
| an empty unit is an error | ingredients ×1, 4, 6 | → | error: unit must not be empty for "flour" |
| sixteen significant digits is an error | ingredients ×1, 4, 6 | → | error: has too many digits |
More from the author
The scaling itself is exact: the quantity is decimal text, multiplied by `toPortions / fromPortions` as a fraction. Then each quantity is rounded **once**, half-up:
| unit | rounded to | written in | |---|---|---| | mg, g, kg | whole grams from 10 g, 0.1 g from 1 to 10 g, 0.01 g below 1 g | g, or kg from 1000 g | | mL, L | the same steps in millilitres | mL, or L from 1000 mL | | each | always **up** to a whole number: half an egg short is short | each | | anything else (oz, lb, cup_us, tbsp, sprig...) | 2 decimal places | unchanged |
So 500 g for 4 is 1.25 kg for 10, 1.2 kg for 4 is 600 g for 2, and 666.4 g scaled by 1.5 is 999.6 g, which rounds to 1000 g and is written `1` kg. Metric amounts are converted with `units.convert`; the kilogram and litre forms keep up to 3 decimal places, which is whole grams and millilitres.
Imperial and cup measures stay in their unit rather than being converted to metric, because a cook reading a cup recipe expects cups back. Units the registry does not know (tbsp, sprig, pinch) are scaled as written.
## Limits and errors
- Portions are whole numbers from 1 to 10000. - A quantity is non-negative decimal text (`"0.5"`, not `"1/2"`), at most 15 significant digits and 15 decimal places. Trailing zeros are ignored. - The unit must not be empty.
## Not covered
Scaling is linear. Real kitchens don't always scale linearly: seasoning, leavening, and cooking times and pan sizes often need adjusting by hand when a recipe is multiplied many times over.
Files
| Path | Bytes |
|---|---|
| README.md | 1,686 |
| impl/python.py | 3,893 |
| impl/rust.rs | 6,171 |
| impl/typescript.ts | 3,699 |
| vectors.json | 5,765 |