Functional Weave
Code in TypeScript

hospitality.recipe-cost

Cost a recipe and each portion from ingredient quantities, pack sizes and pack prices, allowing for trim and waste.

1.0.1 · published 2026-10-03 by charlie · Anterra

Pinned by 25 tests, run in TypeScript, Python and Rust.

What it does

Costs a recipe the way a kitchen costing sheet does: what each ingredient costs in the recipe, the total, and the cost of one portion.

For each ingredient:

For example

  • recipeCost(ingredients ×4, 8, GBP) → lines ×4, total £3.93, portions 8, per portion £0.49 a batter for 8: grams from a kilo bag, a whole pack, eggs by the dozen, millilitres from a litre bottle
  • recipeCost(ingredients ×1, 4, GBP) → lines ×1, total £0.23, portions 4, per portion £0.06 yield grosses up: 400 g peeled at 80% yield means 500 g bought, not 320 g
  • recipeCost(ingredients ×1, 2, GBP) → lines ×1, total £1.50, portions 2, per portion £0.75 imperial: 8 oz from a 1 lb pack

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 recipeCost(ingredients: readonly RecipeIngredient[], portions: number, currency: string): RecipeCost
ingredientsRecipeIngredient[]at least one
portionsinthow many portions the recipe makes, at least 1
currencystringthe currency every pack price is in
returnsRecipeCost

The types it declares, generated into your project

/** One line of a recipe, and how the ingredient is bought. */
export interface RecipeIngredient {
  readonly name: string;
  /** usable amount the recipe needs, as decimal text such as "250" */
  readonly quantity: string;
  /** a units.convert symbol ("g", "kg", "mL", "L", "oz", "lb"...) or "each" */
  readonly unit: string;
  /** share of what is bought that is usable after trim and waste; 10000 = none lost */
  readonly yieldBasisPoints: number;
  /** how much one pack holds, as decimal text, at most 6 decimal places */
  readonly packSize: string;
  /** unit of packSize, the same dimension as unit */
  readonly packUnit: string;
  /** the price of one pack */
  readonly packPrice: Money;
}

/** What one ingredient costs in the recipe. */
export interface RecipeCostLine {
  readonly name: string;
  readonly cost: Money;
}

/** The recipe's cost, line by line and per portion. */
export interface RecipeCost {
  readonly lines: readonly RecipeCostLine[];
  /** the lines added up */
  readonly total: Money;
  readonly portions: number;
  /** total / portions, rounded half-up */
  readonly perPortion: Money;
}

Your code names it in one line, in the file that uses it

import { recipeCost } from "#fune/hospitality.recipe-cost@^1";
impl/typescript.ts · 80 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import { roundDiv } from "./math_round_div.ts";  ← from math.round-div ^1.0.0 · built alongside by fune
import { money } from "./money_amount.ts";  ← from money.amount ^1.0.0 · built alongside by fune
import { sumMoney } from "./money_sum.ts";  ← from money.sum ^1.0.0 · built alongside by fune
import { convertUnits } from "./units_convert.ts";  ← from units.convert ^1.0.0 · built alongside by fune
import { type RecipeCost, type RecipeCostLine, type RecipeIngredient } from "./hospitality_recipe_cost_types.ts";

const DECIMAL = /^[0-9]+(\.[0-9]+)?$/;
const I128_MAX = (1n << 127n) - 1n;
// Quantities are carried to 9 decimal places of the pack unit: a microgram of
// a kilogram pack, far below anything a kitchen weighs.
const QUANTITY_DECIMALS = 9;

function parseDecimal(text: string): [bigint, number] {
  const [whole, fraction = ""] = text.split(".");
  return [BigInt(whole + fraction), fraction.length];
}

function lineCost(ingredient: RecipeIngredient): number {
  const { name, quantity, unit, yieldBasisPoints, packSize, packUnit, packPrice } = ingredient;
  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 [packDigits, packScale] = DECIMAL.test(packSize) ? parseDecimal(packSize) : [0n, 0];
  if (!DECIMAL.test(packSize) || packDigits === 0n || packScale > 6 || packDigits.toString().length > 15) {
    throw new RangeError(`packSize must be a positive decimal with at most 6 decimal places and 15 digits, received "${packSize}" for "${name}"`);
  }
  if (!Number.isInteger(yieldBasisPoints) || yieldBasisPoints < 1 || yieldBasisPoints > 10000) {
    throw new RangeError(`yieldBasisPoints must be from 1 to 10000, received ${yieldBasisPoints} for "${name}"`);
  }
  if (packPrice.minor < 0) throw new RangeError(`packPrice must not be negative, received ${packPrice.minor} for "${name}"`);

  let inPackUnits: string;
  if (unit === "each" || packUnit === "each") {
    if (unit !== packUnit) throw new RangeError(`cannot convert ${unit} to ${packUnit}: "each" only converts to "each"`);
    if (parseDecimal(quantity)[1] > QUANTITY_DECIMALS) {
      throw new RangeError(`quantity may have at most ${QUANTITY_DECIMALS} decimal places, received "${quantity}" for "${name}"`);
    }
    inPackUnits = quantity;
  } else {
    inPackUnits = convertUnits(quantity, unit, packUnit, QUANTITY_DECIMALS);
  }

  // cost = price x (quantity / packSize) / yield, in one exact division.
  const [qtyDigits, qtyScale] = parseDecimal(inPackUnits);
  let numerator = BigInt(packPrice.minor) * qtyDigits * 10000n;
  let denominator = packDigits * BigInt(yieldBasisPoints);
  if (packScale >= qtyScale) numerator *= 10n ** BigInt(packScale - qtyScale);
  else denominator *= 10n ** BigInt(qtyScale - packScale);
  if (numerator > I128_MAX || denominator > I128_MAX) {
    throw new RangeError(`the cost of "${name}" is too large to compute exactly`);
  }
  const rounded = (numerator * 2n + denominator) / (denominator * 2n);
  if (rounded > BigInt(Number.MAX_SAFE_INTEGER)) {
    throw new RangeError(`the cost of "${name}" is too large to compute exactly`);
  }
  return Number(rounded);
}

/**
 * The cost of a recipe, each ingredient and each portion.
 *
 * Each ingredient's quantity is converted to the unit its pack is sold in
 * (250 g of a 1.5 kg bag), grossed up for trim and waste (400 g of peeled
 * carrots needs 500 g bought at an 80% yield), and priced as that share of the
 * pack, rounded half-up to the minor unit. The total is the sum of those line
 * costs, so the costing sheet adds up.
 */
export function recipeCost(ingredients: readonly RecipeIngredient[], portions: number, currency: string): RecipeCost {
  if (ingredients.length === 0) throw new RangeError("a recipe needs at least one ingredient");
  if (!Number.isInteger(portions) || portions < 1) throw new RangeError(`portions must be at least 1, received ${portions}`);
  const lines: RecipeCostLine[] = ingredients.map((ingredient) => ({
    name: ingredient.name,
    cost: money(lineCost(ingredient), ingredient.packPrice.currency),
  }));
  const total = sumMoney(
    lines.map((line) => line.cost),
    currency,
  );
  return { lines, total, portions, perPortion: money(roundDiv(total.minor, portions, "half-up"), currency) };
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 4 dependencies, 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-cost
Download for TypeScript hospitality.recipe-cost-1.0.1-typescript.fune · 21,491 bytes sha256 3353904067033d05b03711d4eb9917bc505935971cde5d131da749c0f97c959f

The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./hospitality.recipe-cost-1.0.1-typescript.fune, or fetch it from a terminal with fune pull hospitality.recipe-cost@1.0.1:typescript.

The whole function, every language, is one file too: hospitality.recipe-cost-1.0.1.fune, 33,471 bytes, sha256 46e7b8e9d2f1a0c701b9782c76ba36ce439ddd530eb27edc42f0b66d35beea5d. 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-cost

after — your function gets the result and the arguments, and returns the final result.

// fune: after hospitality.recipe-cost

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 math.round-div in hospitality.recipe-cost
// fune: replace money.amount in hospitality.recipe-cost
// fune: replace money.sum in hospitality.recipe-cost
// fune: replace units.convert in hospitality.recipe-cost

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-cost --steps.

// fune: step hospitality.recipe-cost 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.

CaseArgumentsExpected
a batter for 8: grams from a kilo bag, a whole pack, eggs by the dozen, millilitres from a litre bottle ingredients ×4, 8, GBP → lines ×4, total £3.93, portions 8, per portion £0.49
yield grosses up: 400 g peeled at 80% yield means 500 g bought, not 320 g ingredients ×1, 4, GBP → lines ×1, total £0.23, portions 4, per portion £0.06
imperial: 8 oz from a 1 lb pack ingredients ×1, 2, GBP → lines ×1, total £1.50, portions 2, per portion £0.75
metric recipe, imperial pack: 100 g of a 1 lb pack is 0.220462262 lb ingredients ×1, 1, GBP → lines ×1, total £1.00, portions 1, per portion £1.00
a pinch costs less than a penny and rounds to nothing ingredients ×1, 1, GBP → lines ×1, total £0.00, portions 1, per portion £0.00
saffron by the gram ingredients ×1, 4, GBP → lines ×1, total £3.20, portions 4, per portion £0.80
a zero quantity costs nothing ingredients ×1, 1, GBP → lines ×1, total £0.00, portions 1, per portion £0.00
the portion cost rounds half-up: 0.10 over 4 is 0.025 ingredients ×1, 4, GBP → lines ×1, total £0.10, portions 4, per portion £0.03
a fractional pack size: 0.75 L bottle of wine ingredients ×1, 1, GBP → lines ×1, total £1.80, portions 1, per portion £1.80
half an egg ingredients ×1, 1, GBP → lines ×1, total £0.17, portions 1, per portion £0.17
Show the other 15 tests
CaseArgumentsExpected
euros ingredients ×1, 2, EUR → lines ×1, total €0.65, portions 2, per portion €0.33
mass priced by volume is an error ingredients ×1, 1, GBP → error: cannot convert g (mass) to L (volume)
each against grams is an error ingredients ×1, 1, GBP → error: cannot convert each to g
an unknown unit is an error ingredients ×1, 1, GBP → error: unknown unit "cup"
a zero yield is an error ingredients ×1, 1, GBP → error: yieldBasisPoints must be from 1 to 10000
a yield over 100% is an error ingredients ×1, 1, GBP → error: yieldBasisPoints must be from 1 to 10000
no portions is an error ingredients ×4, 0, GBP → error: portions must be at least 1
an empty recipe is an error , 1, GBP → error: a recipe needs at least one ingredient
a negative quantity is an error ingredients ×1, 1, GBP → error: quantity must be a non-negative decimal
a zero pack size is an error ingredients ×1, 1, GBP → error: packSize must be a positive decimal
a pack size with 7 decimal places is an error ingredients ×1, 1, GBP → error: packSize must be a positive decimal
a negative pack price is an error ingredients ×1, 1, GBP → error: packPrice must not be negative
a pack priced in another currency is an error ingredients ×1, 1, GBP → error: currency mismatch
a quantity with a trailing newline is an error ingredients ×1, 1, GBP → error: quantity must be a non-negative decimal
a pack size with a trailing newline is an error ingredients ×1, 1, GBP → error: packSize must be a positive decimal

More from the author

1. **Convert** the quantity the recipe needs into the unit the pack is sold in, with `units.convert` (250 g of a 1.5 kg bag is 0.25 kg). Count items use the unit `each`, which only converts to `each` (3 eggs from a tray of 12). 2. **Gross up for yield.** `quantity` is the usable amount the recipe needs after trimming and peeling. `yieldBasisPoints` is the usable share of what is bought: 400 g of peeled carrots at an 8000 (80%) yield means buying 500 g. A common mistake multiplies by the yield instead, costing 320 g. 3. **Price it** as that share of the pack price, in one exact division, rounded half-up to the minor unit.

The total is the sum of the rounded line costs, so the sheet adds up, and the portion cost is the total divided by `portions`, rounded half-up.

## Precision

Quantities and pack sizes are decimal text (`"0.75"`), never floats. The converted quantity is carried to 9 decimal places of the pack unit, a microgram of a kilogram pack, and everything after that is exact integer arithmetic. Pack sizes may have at most 6 decimal places and 15 digits. An ingredient whose cost cannot be held exactly (beyond 2^53 minor units) is an error rather than a rounded guess.

A line under half a penny (a pinch of salt) costs 0. Kitchens that want those counted usually add a "sundries" line as a fixed amount.

## Errors

- Units must be ones `units.convert` knows (`g`, `kg`, `mL`, `L`, `oz`, `lb`, `floz_imp`...) or `each`, and a quantity and its pack must be the same dimension: oil costed in grams but bought by the litre needs a density, which this does not guess. - `yieldBasisPoints` is 1 to 10000, `portions` at least 1, pack prices not negative, and every pack price in `currency`.

1.0.1 fixes Python accepting a trailing newline in quantity and packSize; adds tests.

Files

PathBytes
README.md2,007
impl/python.py4,432
impl/rust.rs7,104
impl/typescript.ts4,153
vectors.json9,733