Chapters
The Bag
Most programming languages give you a shelf full of containers — arrays for ordered lists, dictionaries for labelled lookups, records for fixed fields — and you have to learn all of them and decide which one fits before you can store a single value. Catena takes a different path. It has exactly one container, called the Bag, and it does the job of all three. Learn the Bag and you have learned every way Catena holds more than one value.
This chapter explains what a Bag is, how to build and read one, and — most importantly — the small set of whole-Bag operations (sums, filters, transforms) that are the natural way to work with collections in Catena. If you have not yet met the store arrow -> or the comparison operators, read Expressions and operators first; this chapter uses them throughout.
What a Bag is
Think of a shopping bag, not a bag of marbles. Marbles are interchangeable; the goods in a shopping bag are not. A shopping bag holds different kinds of things — a carton of milk, three apples, a receipt — each keeping its own identity, all sitting in the order you dropped them in. A Bag in Catena is exactly this:
- Ordered. Items stay in the order you put them. The first item is the first item, always.
- Heterogeneous. One Bag can hold a number, some money, a date, and a piece of text side by side. Items do not have to share a type.
- Identified. Every item can carry an optional name (a label) and optional extra properties, so a single Bag can behave like a list, like a labelled dictionary, or like a record with fields — whichever you need.
Because one container covers all three roles, you never have to stop and choose a data structure. You put things in a Bag and get on with the calculation.
Building a Bag
You write a Bag with square brackets [ ], listing its items separated by commas, and store it with the -> arrow:
[150, 200, 99.50] -> pricesThat makes a three-item Bag and names it prices. The brackets are what mark it as a Bag. This matters for the one-item case:
[5] -> single # a Bag holding one item, the number 5
(5) # just the number 5 — round brackets group, they never make a BagSquare brackets build a Bag; round brackets only group an expression. So [5] and (5) are genuinely different things, and there is no special rule to remember for single-item Bags.
An empty Bag is written either way:
BAG -> results # an empty Bag, ready to fill
[] -> results # the same thingBecause items keep their own type, a mixed Bag is perfectly ordinary:
["Total due", 249.50, 2026-07-01] -> lineHere one Bag holds a piece of text, an amount, and a date, in that order.
Reading items: position and name
You reach into a Bag with square brackets, the same brackets you built it with. There are two ways to point at an item.
By position (1-based). The first item is number 1, not 0:
prices[1] # 150 — the first item
prices[3] # 99.50 — the third itemCounting from one matches how a calculator and a spreadsheet number their cells, so a value at position 3 is the third value, with no off-by-one surprise.
By name. An item can carry a label, and you look it up with that label in quotes:
results["grand_total"]Storing into a name adds the item if it is new, or replaces it if that name already exists:
total -> results["grand_total"]COUNT tells you how many items a Bag holds:
COUNT(prices) # 3Copies, not shared references
A Bag is a value, and assigning it makes a copy. This is the single most important thing to understand about Bags, because it removes a whole category of confusing bugs.
prices -> backup # backup is now an independent copy
299 -> backup[1] # change the copy...
prices[1] # ...still 150. The original is untouched.After prices -> backup, the two Bags go their separate ways. Changing backup never reaches back and alters prices. There is no hidden link between them — nothing like the "I changed one list and the other changed too" trap that catches people in languages where containers are shared references. If you want a working copy you can experiment with, just store the Bag into a new name; it is a real, separate duplicate, exactly like photocopying a shopping list before you start crossing items off.
Working on the whole Bag
The natural way to think about a collection is usually "do something to all of it": total these prices, find the largest, add tax to every line. Catena supports that directly, and these whole-Bag operations are the preferred style — reach for them before writing a loop. There are three kinds: aggregates, element-wise arithmetic, and queries.
Aggregates collapse a Bag down to one answer. Each takes a Bag and returns a single value:
SUM(prices) # 449.50 — everything added up
AVERAGE(prices) # the mean of the items
MIN(prices) # the smallest item — 99.50
MAX(prices) # the largest item — 200
COUNT(prices) # how many items — 3So the total and average of a shopping list are one call each — no loop, no running variable.
Element-wise arithmetic applies an operation to every item at once and hands back a new Bag, leaving the original alone:
prices * 1.23 -> with_vatwith_vat is a fresh three-item Bag holding each original price multiplied by 1.23. The prices Bag is unchanged. This reads the way you would say it out loud — "the prices, plus tax" — and works for the other arithmetic operators too.
Filtering with a query: ALL … IN … WHERE …
To pick out just the items that meet a condition, use a query. It reads almost like English and returns a smaller Bag — a sub-bag — containing only the matching items:
ALL p IN invoices WHERE p.amount > 100 -> bigRead it left to right: all p in invoices where p.amount is greater than 100. The name p stands for one item at a time as Catena looks through invoices; p.amount reaches the amount property of that item; and every item for which the condition is true goes into the result big. Items that do not match are simply left out. The original invoices Bag is not changed — a query reads, it does not remove.
Queries are also the reason you rarely need to phrase a selection with AND/OR. "Give me the matching items" is exactly what WHERE expresses, so the common filtering case has its own clear form rather than a chain of Boolean conditions. (The Boolean operators still exist for genuine yes/no tests — see Expressions and operators.)
Transforming with a map: EACH … IN … : …
When you want a new Bag built by transforming every item — not just selecting some — use a map. It names each item, gives an expression to compute from it, and stores the resulting Bag:
EACH p IN prices : p * 1.23 -> with_vatRead it as: for each p in prices, compute p * 1.23, and collect the results into with_vat. Where the element-wise form prices * 1.23 is the quick way to apply one arithmetic operation, the map is the explicit, general form: the expression after the colon can be anything, so you use a map when the transformation is more than a single operator — combining an item with a lookup, formatting it, or calling a function on it.
The map builds a brand-new Bag and never disturbs the source. prices still holds the original values after the map runs.
Choosing a form
The three whole-Bag styles cover most collection work between them:
| You want to… | Use | Result |
|---|---|---|
| Reduce a Bag to one number | SUM / AVERAGE / MIN / MAX / COUNT |
a single value |
| Apply one arithmetic operation to every item | element-wise, e.g. prices * 1.23 |
a new Bag, same size |
| Keep only the items that match a condition | ALL p IN … WHERE … |
a smaller Bag (sub-bag) |
| Build a new item from each old one | EACH p IN … : … |
a new Bag, same size |
Reach for these first. Catena does have a FOR EACH loop for the cases these cannot express — step-by-step work where each item's handling depends on the last — but for totalling, filtering, and transforming, the forms above are shorter, clearer, and the idiomatic choice. The loops, and when they earn their place, are covered in Control flow.
A worked example, start to finish
Suppose you are pricing a small order. Build the Bag of unit prices, total it, add tax to every line, and pull out the expensive items:
[150, 200, 99.50] -> prices # 1. build the Bag
SUM(prices) -> subtotal # 2. subtotal is 449.50
prices * 1.23 -> with_vat # 3. each price plus 23% tax, as a new Bag
ALL p IN prices WHERE p > 100 -> big # 4. big holds 150 and 200
COUNT(big) -> how_many # 5. how_many is 2Step by step: line 1 creates prices; line 2 collapses it to a single subtotal; line 3 produces a parallel Bag with_vat without touching prices; line 4 selects the items above 100 into a sub-bag; line 5 counts them. At the end you still have the untouched original prices, plus three derived results — a subtotal, a taxed list, and a filtered list — each computed with one line and no loop.
Related chapters
- Expressions and operators — the store arrow
->, arithmetic, and the comparisons used insideWHERE. - Control flow —
IF,REPEAT, and theFOR EACHloop for the work aggregates and queries cannot express. - Commands and the standard library — the full set of built-in functions, including the aggregates.