The standard operator table
On this page
Operators on standard types work by one fixed table rather than a
library function per job, so price * 20% and distance / 2h read as
they would on paper. The compiler knows each row and checks an operator
as strictly as a call. Anything not in the table is a compile error
naming the two types.
The table extends the rule for time in
Arithmetic and comparison,
where Instant - Instant gives a Duration. The rules for numbers and
for + on text in
Expressions and operators still
hold.
| Left | Operator | Right | Gives | Notes |
|---|---|---|---|---|
Instant |
+, - |
Duration |
Instant |
Time |
Instant |
- |
Instant |
Duration |
Time |
Duration |
+, - |
Duration |
Duration |
Time |
CalendarDate |
+, - |
CalendarPeriod |
CalendarDate |
Years, then months, then days; a day past the month's end clamps to its last day |
CalendarPeriod |
+, - |
CalendarPeriod |
CalendarPeriod |
Part by part (P1M + P10D is P1M10D) |
CalendarPeriod |
* |
Int |
CalendarPeriod |
Each part multiplied |
| A quantity | +, -, *, / |
as tabled | as tabled | "Physical quantities" below |
Money |
+, -, < ... |
Money |
Money, Bool |
One currency (below) |
Money |
* |
a number or Percentage |
Money |
Rounded by the type's rounding: |
Money |
/ |
Decimal |
Money |
A rate (/ 1.20 takes VAT off), rounded by the type's rounding: |
Money |
/ |
Money |
Decimal |
One currency; as / gives for Decimal (Logic) |
Money |
/ |
Int |
refused | type.money_split: use money.split or money.allocate |
Money |
+, - |
Percentage |
refused | Ambiguous: write price * 120% or price + price * 20% |
Percentage |
+, - |
Percentage |
Percentage |
20% + 5% is 25% |
Percentage |
* |
a number or Percentage |
Percentage |
50% * 20% is 10% |
| A number or quantity | * |
Percentage |
the same kind | distance * 10% |
Money
- Money in one currency. Where both types fix the currency
(
type CakePrice = Money { currency: Currency.gbp }), adding two currencies is a compile error. Where a currency is known only while the program runs, as in a SaaS app billing each customer in their own,+,-and comparisons can fail with the conditioncurrency_mismatch. You map it as any construction rule is mapped:attempt subtotal + line.amount { currency_mismatch: InvoiceCurrencyMismatch }. Insideif (money.currency(a) == money.currency(b))the checker has the proof and the operator is plain, as a range proves a construction (Conditions as proof). - Two amounts follow the named-type rule in
Expressions and operators:
+,-and/take two amounts of one named type, or one and a plain amount that an operator or amoneyoperation made. A currency that both sides fix differently is reported first, astype.arithmetic_operandortype.ordering_operand. - What the checker knows of a result's currency. Operators give a
plain
Money, and the checker carries what it knows of its currency, as it carries a number's range: a type'scurrency:, a currency written out inmoney.from_minor,oforsum, one currency through+,-,*and/, and a condition that compares currencies. ==and!=never fail. Two amounts in different currencies are unequal, as the contract's==says. Only<,<=,>and>=need one currency.- An
attemptaround a chain of operators covers every operator in it, as inattempt a + b + c { currency_mismatch: ... }, but not an operator inside a call. In anattempt, a block straight after a local's value (line.amount { ... }) is the attempt's mappings, not a value built from a block, since only a type builds one.attemptstill starts a statement, so a comparison underattemptis written as avar. - The money is written first:
price * 2, never2 * price, which istype.arithmetic_operand, whose next step writes it the other way.%,-priceandprice * priceare refused.Money / Moneytakes a divisor as/does, a type whoseminis at least 1, and gives/'sDecimal. - Rounding is the type's. A
Moneytype may declarerounding:(type CakePrice = Money { currency: Currency.gbp rounding: Rounding.half_up }).*and/round to a whole minor unit by the left amount's type'srounding:, or byhalf_evenwhere it declares none, as constructing into a type with a fixedscalerounds (Values and types). So multiplying needs no call, and there is nomoney.multiplyormoney.divide. /by anIntis refused, with a fix tomoney.splitormoney.allocate. Dividing money by a count is almost always sharing it out, and rounding division loses a minor unit:£10.00 / 3is£3.33, and three of those make£9.99.splithands every penny to someone./by aDecimalis allowed, because that is a rate, not a share:gross / 1.20takes VAT off. Halving a price isprice * 50%.
Percentages
Percentageis a type built into the language, held as the exact fraction it is (20%is 0.20). LikeMoney, it is a representation: a signature names a type built on it (type VatRate = Percentage { min: 0% max: 100% }), whosemin:andmax:are written with%, while avarmay be a plainPercentage. Percentages are ordered.- The
%literal.20%is aPercentage: digits and%with nothing between. Its fraction keeps the places written and two more (12.5%is 0.125). It may be negative (-5%), as a quantity literal may. %as remainder (Expressions and operators) is always written with spaces, as the formatter writes every binary operator, so the two never meet in formatted code. A number,%and a name or number all touching (20%3,20%rate) issyntax.percent_ambiguous, naming both readings. It offers no edit, since either could be meant, and the text is read as the remainder so the file still parses.- A bare number where a percentage goes is refused, as an argument,
in a comparison, beside
+or as a bound, since confusing 0.2 and 20 is what the type exists to end. The error istype.mismatch(ortype.incomparable,type.ordering_operand,type.arithmetic_operandortype.setting_value, depending on where the number stands). Its next step names both readings,25%if0.25is a ratio, or0.25%, with no edit. - A ratio becomes a
Percentageby construction. Dividing two quantities gives a plainDecimal(lip / total), andPercentage(ratio)makes it a percentage for display:0.25gives25%. Every value becomes a named type this way, so there is nounitsoperation for it. Percentage(ratio)takes aDecimal, a value or a decimal literal, intoPercentageor any type built on it, checking that type's rules. A whole number (VatRate(20)) or anIntvalue istype.constructor_input, naming both readings. A named percentage never becomes the plainPercentage, as for any representation (Values and types).- A percentage scales what it follows. "The same kind" in the table,
for a number, is a number:
Int * Percentagegives aDecimal, as*by aDecimaldoes, rather than rounding to a whole number. A percentage is written after the amount, number or quantity it scales, as a number is (price * 20%).20% * priceand15% * lengthare refused, with the fix written the other way, while20% * 3is a percentage, as the table's own row says. A quantity times a percentage rounds half to even to its kind's scale, as it does when multiplied by a number.+and-of two percentages follow the named-type rule. - No other row. Nothing divides a percentage (
rate / 2is refused;rate * 50%halves it), adds one to a number or a quantity, or negates a percentage value (-rate). A quantity and a percentage are reported as different kinds.Money + Percentageistype.arithmetic_operand, whose next step writes both readings with the percentage worked out:price * 120%orprice + price * 20%(price * 80%orprice - price * 20%for-), and for a percentage value,price * (100% + rate). - A percentage's text and wire. The canonical text is the fraction
as a percentage with no trailing zeros:
20%,12.5%.text.parsereads a number, one optional space and%, since French and German write20 %; anything else ismalformed. On the wire it is that text,"20%", rather than the fraction aDecimalwould send, so no client reads 0.2 as 0.2%. A JSON number is the problemtype, text without%isformat, and OpenAPI shows a string with its pattern. - A percentage in storage is one
numericcolumn holding the fraction, 0.15 for 15%, at whatever places the value has. - A percentage for people.
text.formatis ICU4X's percent formatter over CLDR's pattern: the locale's digits and separators, every place the value keeps as a percentage, and the%where the locale puts it, as in12.5%, or20 %inde_dewith a no-break space. It takes no options.Displaywrites20%. - A percentage in a message. In a message's wording,
{rate, number, percent}is the style for a field built onPercentage, as{price, number, currency}is money's, and shows it astext.formatdoes.{rate}alone for a percentage, or either style on a field of another kind, istranslation.placeholder_style, whose edit writes the right one. Any other style istranslation.wording_invalid.
Ratios and fractions
Ratio and Fraction are standard objects. A Ratio's numerator and
denominator are Decimals of any value, as an exchange rate's are. A
Fraction's are whole numbers, its denominator at least 1.
Each is held as written, so 16:9 and 32:18, or 1/2 and 2/4, are
two different values, as any object's fields compare. Neither is
ordered: no operation reduces them or compares them by value.
Their canonical texts are 16:9 (each number with the places it was
given) and 3/4, with no spaces, and they are read back exactly so; a
denominator below 1 is malformed. Each is sent as that text, shown as it
whatever the locale, and stored in a column per part: numeric for a
Ratio and bigint for a Fraction. type.standard_exists matches an
object of a numerator and a denominator, by what they are built on.
No user-defined operators
Your app's types can't declare operators, so a + b always means a row
of this table or of
Expressions and operators. A
reviewer never has to find an overload to know what an operator does,
whether it can fail or what it costs.
VAT, offcuts and pass rates
A bakery adds VAT to its cakes, trims its shelves and reports how much of a batch passed, each with a percentage:
type CakePrice = Money { currency: Currency.gbp}type VatRate = Percentage { min: 0% max: 100%}type PassRate = Percentage {}type Offcut = Length {}type Share = Decimal { scale: 4}/// The VAT on a cake's price at `rate`, rounded half to even to a penny.function vat_on(price: CakePrice, rate: VatRate) -> CakePrice { return CakePrice(price * rate)}/// A cake's price with VAT added: the whole price and the rate on top.function with_vat(price: CakePrice, rate: VatRate) -> CakePrice { return CakePrice(price * (100% + rate))}/// What is trimmed off a shelf cut from a 2.5262 m board: 15% of it.function offcut() -> Offcut { var total: Length = 2metre + 45cm + 3inch var trim: Length = total * 15% return Offcut(trim)}/// The offcut for the customer, in centimetres.function offcut_label(customer: Locale) -> PresentationText { return text.format(offcut(), locale: customer, unit: LengthUnit.centimetre)}/// How much of a batch passed, from the share that did.function pass_rate(good_share: Share) -> PassRate { return PassRate(good_share)}/// A rate for the customer, as their locale writes a percentage.function rate_label(rate: PassRate, customer: Locale) -> PresentationText { return text.format(rate, locale: customer)}/// Each of four people's share of a cake.function cake_share() -> Fraction { return Fraction { numerator: 1 denominator: 4 }}
test "a percentage scales money, numbers and quantities" { var cake: CakePrice = CakePrice(money.from_minor(1250, Currency.gbp)) assert vat_on(cake, 20%) == money.from_minor(250, Currency.gbp) assert with_vat(cake, 20%) == money.from_minor(1500, Currency.gbp) var penny_cake: CakePrice = CakePrice(money.from_minor(100, Currency.gbp)) assert vat_on(penny_cake, 12.5%) == money.from_minor(12, Currency.gbp) assert 50% * 20% == 10% assert 20% + 5% == 25%}test "the offcut is exact in metres and shown in centimetres" { assert offcut() == 0.37893metre assert offcut_label(Locale.en_gb) == "37.893 cm" assert offcut_label(Locale.de_de) == "37,893 cm"}test "a ratio becomes a percentage, and fractions are written as people write them" { assert pass_rate(0.25) == 25% assert rate_label(pass_rate(0.175), Locale.en_gb) == "17.5%" assert text.from(pass_rate(0.175)) == "17.5%" assert text.from(cake_share()) == "1/4" assert cake_share().denominator == 4}
A voucher keeps its discount in one numeric column, as the fraction it
is. Here Voucher is an entity, a kind
of stored record:
use bakery/types { DiscountRate }/// A voucher a customer brings to the counter.entity Voucher { id: Uuid reduction: DiscountRate // one `numeric` column: 0.15 for 15%}
use bakery/entities { Voucher }test "a voucher keeps its discount as it was issued" { var voucher_made: Voucher = issue_voucher(15%) var voucher_on_file: Voucher = attempt Voucher.get(voucher_made.id) assert voucher_on_file.reduction == 15% assert text.from(voucher_on_file.reduction) == "15%"}
Markdown for agents: /docs/the-standard-operator-table.md.Built from Jadpo main at a707efb.