Skip to main content

Operators

Most operators are infix operators: they have two operands, a left-hand side (lhs) operand and a right-hand side operand (rhs).

An infix operator can either have whitespace before and after the operator or have no whitespace neither before nor after the operator.

Infix operators have a precedence that indicate how strongly they bind to their operand and a left or right associativity.

A few operators are prefix operators: they only have a right-hand side. Prefix operators are followed immediately by their operand: they cannot be separated by whitespace.

A postfix operator (!, Factorial) has only a left-hand side and follows it immediately: like a prefix operator, it cannot be separated from its operand by whitespace.

info

The whitespace rules are necessary to support unambiguous parsing of expressions spanning multiple lines without requiring a separator between expressions

The table below is the complete set of operators, with their spelling, precedence and associativity — if a symbol is not listed there, it is not an operator.

Precedence​

The operator at the root of the parse tree has the lowest precedence.

Precedence tiers are numbered in gaps of 10, loosest to tightest — a higher number binds tighter. Operators in the same tier have the same precedence (for example + and -, or * and /).

TierOperatorASCIIFancyKindAssociativity
10Assign:=infixright
—Assign or Equal=infixpositional
15MapsTo=>⇒infixright
18Coalesce??infixright
20Pipe|>infixleft
20Pipe~>infixleft
30KeyValuePair->→infixleft
40Or||⋁infixleft
50And&&⋀infixleft
60Equal==infixn-ary chain
60Same===infixn-ary chain
60NotEqual!=≠infixn-ary chain
60Less<infixn-ary chain
60Greater>infixn-ary chain
60LessEqual<=⩽infixn-ary chain
60GreaterEqual>=⩾infixn-ary chain
60Elementin∈infixn-ary chain
60Element (type test)isinfix
60NotElement!in∉infixn-ary chain
65Range..‥infixleft
70Add+infixleft
70Subtract-−infixleft
80Multiply*×infixleft
80Divide/÷infixleft
80Mod%infixleft
90Negate-−prefix
90Not!¬prefix
100Power^infixright
100Power**infixright
101Sqrt, Root√ ∛ ∜prefix
110Factorial!postfix
110Powerx² xⁿ⁺¹postfix
110Subscriptxₖ₊₁postfix

Postfix calls and indexing (f(x), xs[i]) bind tighter than every entry in this table — they are handled directly by the parser rather than through the operator table, since they are not spelled with an operator symbol.

The three Unicode-only rows — the radical signs, and the superscript and subscript runs — have no ASCII spelling. By default the serializer writes the ASCII forms sqrt(x), x ^ 2 and Subscript(x, k + 1). In its fancy-symbol mode (the fancySymbols option of serializeEpsil, or epsil --epsil --fancy-symbols) it writes √x, ∛x, ∜x, and an integer-literal exponent as a superscript (x², x⁻¹); a symbolic exponent keeps ^, and a subscript expression keeps Subscript(…). See Radical signs and Superscripts and subscripts.

The conditional expression a if c else b is not an operator row either, but it has a place in this order: between KeyValuePair (30) and Or (40), so it binds looser than every operator that computes and tighter than the forms that bind or pair (=, =>, |>, ->). See Control Flow.

The whitespace rule​

An infix operator must have whitespace on both sides or on neither side. A prefix operator must have no whitespace before its operand. These rules let a multi-line program parse deterministically without a separator between every expression:

a + b // infix addition
a+b // same: whitespace on neither side
a +b

Here + has whitespace before but not after: it is not treated as infix. The expression a ends there; +b is left over on the same line with no separator before it, which is a diagnostic (unexpected-symbol) rather than a silently-inferred sequence — see Statements and Sequencing. On its own line (after a linebreak or ;), +b is a valid new statement: unary + is the identity, so a and +b are simply two statements.

a+ b

Here + has whitespace after but not before: an asymmetric case. The parser recovers as infix Add but reports an asymmetric-operator-whitespace diagnostic (with a fix-it), since this is more useful to the author than silently ending the statement.

Pipe: |> and ~>​

x |> f is f(x). Chained, it lets a sequence of transformations be read in the order they happen instead of inside-out:

⌘/Ctrl + Enter

A stage that takes more than one argument is written as a call, with _ in the slot the piped value fills:

⌘/Ctrl + Enter

The _ may be left out: a call stage that is missing required arguments receives the piped value in the first slot its type fits, so xs |> take(10) means xs |> take(_, 10) and xs |> map(f) means xs |> map(f, _) (the mapping function is map's first argument). This only fills a hole — a call that is already complete keeps its ordinary meaning, and an explicit _ anywhere in the call says exactly where the piped value goes.

A stage may also be a lambda, written inline without parentheses — after |> the arrow binds tighter than the pipe, and the lambda's body ends at the next |>. A pipe is a call written the other way round: xs |> f is f(xs), for a lambda as for a named function. So when the piped value is a list (or a range), a one-parameter lambda whose body uses its parameter as a scalar (x^2, x + 1) is applied to each element, at every depth of a nested list, as calling it on the list does; _^2 is shorthand for such a lambda. A set, a tuple (a point) or a string is passed whole. The following three pipelines are equivalent:

⌘/Ctrl + Enter
1..oo |> take(10) |> x => x^2 |> sum
1..oo |> take(10) |> _^2 |> sum

Note the two readings of _: in a call stage it is the piped value (take(_, 10)); in an operator-written stage (_^2, _ + 1) it is the parameter of the lambda. A lambda that uses its parameter as a collection receives the whole value, as the same call would: xs |> l => length(l) and xs |> (l: list<number>) => length(l) are the length of xs. A named function stage is called the same way: xs |> sum sums the collection.

A pipe hands its stage exactly one value, so a stage that declares more than one parameter is a pipe-stage-arity error rather than a partial application — a leftover function is never what a pipeline was written to produce:

[100, 200] |> (x, y, z) => x + y + z
// ✘ A pipe passes its stage exactly 1 value; `(x, y, z) => …` declares 3

The fix is the call form above, with _ marking the piped value's slot (xs |> fold(f, 0, _)). The same applies to a named stage: xs |> add on a two-parameter add is this error, not a partially applied add.

When the piped value is a collection whose elements are tuples and you want to name their components, use a tuple pattern parameter — the extra parentheses are what make it one parameter taking a pair:

⌘/Ctrl + Enter

|> and ~> are aliases for Pipe and sit at the loosest precedence tier, right below Assign — looser than arithmetic, relational, and boolean operators (Elixir-style). It is left-associative, so a |> f |> g is g(f(a)):

a + b |> f // (a + b) |> f
a || b |> f // (a || b) |> f
x = a |> f // x = (a |> f)

Absence coalescing: ??​

a ?? b is Coalesce(a, b): the value of a unless a is absent (missing or NaN), in which case the value of b. It is lazy — b is not evaluated when a is present.

let timeout = config.timeout ?? 30
let first = xs[1] ?? 0

?? discharges absence. It does not rescue an Error: an error operand is an error, not a missing value, and propagates.

A function does not accept an absent value at a parameter annotated with a type, unless the type says so. With function f(p: tuple<number, number>), the call f(first(filter(xs, c => c[1] > 0))) is reported by the static check, because a filter can find nothing, and it is an incompatible-type error when the value is absent. Write f(first(filter(…)) ?? (0, 0)) to give a fallback, or annotate the parameter tuple<number, number> | missing and test isMissing(p) in the body. A parameter with no annotation receives the absent value, and a parameter annotated with a numeric type (number, integer, real) reads it as NaN, which such a parameter accepts. The first element of a list literal that has one (first([(1, 2), (3, 4)])) cannot be absent and needs no fallback.

It is right-associative, so a chain falls through left to right:

a ?? b ?? c // Coalesce(a, Coalesce(b, c))

Its precedence (18) sits between => and |>, which fixes the two groupings that matter:

xs |> f ?? 0 // (xs |> f) ?? 0 — the default is for the pipeline's RESULT
x => x.a ?? 0 // x => (x.a ?? 0) — the default is inside the body

Like |>, it is looser than ->, so a dictionary value needs parentheses:

{a -> 1, b -> x ?? 2}

Write {a -> 1, b -> (x ?? 2)} instead. It is also looser than || and && (the C# position), so a ?? b || c is a ?? (b || c).

Type test: is​

x is integer tests at runtime whether a value inhabits a type. It is the same test a match type pattern performs, and lowers to the same Element(value, type) expression:

x is integer
x is string && y is boolean

The right operand is a type name, not an expression, so a typo is a parse-time diagnostic rather than a comparison against an undeclared symbol. This first version resolves simple named types only: a compound type (!error, integer | string, list<integer>) parses but reports type-pattern-unsupported, exactly as the equivalent typed pattern does.

is is a contextual word, not a reserved one — it is recognized only between an operand and a type name, so let is = 5 and f(is) remain legal.

Since is and in express the same membership test, a program written back out from its parsed form uses in for both.

Anonymous functions: =>​

The mapsto operator constructs an anonymous function:

x => x^2
(x, y) => x + y

It is right-associative, so x => y => x + y constructs a function that returns another function. It binds tighter than assignment but more loosely than the other expression operators, so f = x => x + 1 assigns the complete function to f. Typed parameters can be written in parentheses:

(x: integer) => x + 1

A parameter can instead be a tuple pattern, written with a second pair of parentheses. It is still ONE parameter — it takes one argument, a tuple, and binds a name to each component:

[(True, True), (True, False)] |> map(((p, q)) => p && q, _)
// ➔ [True, False]

The doubled parentheses are the whole difference: (p, q) => p && q is the two-parameter function it has always been, and ((p, q)) => p && q is the one-parameter function that takes a pair apart. Patterns mix with plain parameters and nest, exactly as in let (a, b) = v — bare names, _ to skip a position, nested (…) patterns, and nothing else (a literal or a per-element type annotation is a diagnostic):

(x, (p, q)) => x + p + q // two parameters, the second destructured
((a, (b, c))) => a + b + c // one parameter, nested
((p, _)) => p // one parameter, second component discarded

The argument must be a tuple of the pattern's shape; anything else yields an incompatible-type error value, the same one the destructuring let produces. A destructuring lambda is interpreted, never compiled: no compile target lowers the tuple match, so a compiled context falls back rather than emit code that binds the wrong names.

Callbacks and arity. An ordinary call with too few arguments partially applies the function — f(1) on a two-parameter f is a function awaiting the second argument. Inside a collection operator that never happens: the operator decides how many arguments the callback receives (map supplies one element per source collection, filter/any/all/count/takeWhile supply one, reduce/fold supply the accumulator and the element), and a lambda whose parameter count cannot match is a callback-arity error at parse/canonicalization time rather than a list of leftover closures. The message names both sides and, for the pair case, the fix:

map((p, q) => p + q, [(1, 2), (3, 4)])
// error: Map calls its callback with 1 argument (each element of the
// collection); `(p, q) => p + q` declares 2 parameters. To take a pair
// apart, use a tuple pattern parameter: ((p, q)) => …

sort (a key or a comparator) and iterate (f(previous) or f(index, previous)) accept either of their two arities; a () => … literal is a constant and fits any slot. A callback whose arity is not statically known — a value typed function or callback<…>, a generic function — is not checked here and is applied as before.

The MapsTo name in the table is internal to parsing: it names the operator, not the function value the expression produces.

The same arrow separates a match case's pattern from its body (pattern [if guard] => body) — one glyph meaning "yields", in both places. Nothing is ambiguous: a case reserves the first => at its own level for itself, so a pattern and a guard always end there, while a case BODY is an ordinary expression in which => builds a lambda:

match n {
0 => x => x + 1 // body is the lambda `x => x + 1`
n if n > 0 => n // guard is `n > 0`, body is `n`
}

A lambda genuinely wanted inside a pattern or a guard is parenthesized: n if (f => f)(n) => n.

The Unicode arrows ⇒ (U+21D2) and ↦ (U+21A6) are both accepted as input aliases for =>. ⇒ is the one the serializer emits — for a lambda and for a match case alike — in its fancy-symbol mode; ↦, the traditional mathematical mapsto glyph, is accepted but never produced.

Earlier versions spelled this arrow |->. That spelling now reports the mapsto-arrow-legacy diagnostic, with a fixit rewriting it to =>; the expression is still parsed as the function it meant.

A -> whose left side is shaped like a parameter list — (x, y) -> x + y, (n: integer) -> n^2, f = x -> x + 1 — is diagnosed as a wrong-arrow typo (with a fixit) and recovered as the intended function: -> builds a key -> value pair, and none of those shapes is a valid key.

Ranges: ..​

The range operator is a compact spelling of a two-argument Range:

1..5 // Range(1, 5)
1..n - 1 // Range(1, n - 1)
k in 1..5 // k in Range(1, 5)

It binds tighter than relational operators and more loosely than addition and subtraction. The Unicode two-dot leader ‥ is an input alias. Serialization uses Range(a, b), and a stepped range continues to use the three-argument call Range(a, b, step).

Spread: ...​

A prefix ... splices a value's elements into the surrounding sequence. It is accepted in two positions — a call argument list and a list literal — and the three-dot token is distinct from the range operator ..; anywhere else ... is a diagnostic. (In a match pattern, ...rest is the same idea in reverse: it collects the remaining elements.)

In a call argument list, ... spreads a tuple into the call's arguments: the tuple's elements become ordinary positional arguments.

f(...t) // t's elements become f's arguments
f(1, ...t, q) // splices between positional arguments
g(...p, ...q) // several spreads splice in order
max(...t) // variadic built-ins accept spreads

In a call, only tuples spread — argument lists are tuple-shaped, so a List (or any other value) is an incompatible-type error. A literal tuple splices immediately; a symbolic argument is spliced when the call evaluates, and until then the call stays symbolic (the spread never binds positionally to a single parameter).

In a list or set literal, ... splices a collection — a list, a set, a range:

⌘/Ctrl + Enter
⌘/Ctrl + Enter

The splice happens at canonicalization: literal collections splice immediately, and a symbolic or lazy segment lowers to the equivalent join expression — a lone spread [...xs] is join(xs), the list materialization of xs, and an infinite segment stays lazy ([...(1..oo), 5] |> Take(3) is [1, 2, 3]). Set literals deduplicate as usual.

Tuples do not spread here — a tuple is a unit (a point, a pair), and splicing it would quietly discard that; spreading one is a spread-tuple error. To use a tuple's elements, convert explicitly: [...ListFrom(t), 3]. A scalar or string operand is an incompatible-type error (a string is a scalar, not a character collection). Note the mirror-image rule in calls: argument lists are tuple-shaped, so there exactly tuples spread.

In a dictionary literal, ... merges the entries of a dictionary. Entries combine left to right and a later entry wins on a key collision — so a literal entry after a spread overrides it, the usual defaults idiom:

⌘/Ctrl + Enter

(Duplicate literal keys are different: they are almost certainly typos, so they keep the literal rule — first wins, with a duplicate-dictionary-key diagnostic.)

A brace of only spreads has nothing to mark it as a dictionary, so it is read as a set-spread. To write a pure merge, lead with the bare -> marker (the same marker as the empty dictionary {->}):

{...a, ...b} // a SET containing the elements of a and b
{->, ...d1, ...d2} // a dictionary merge of d1 and d2

Unary prefix: - and !​

- (Negate) and ! (Not) are prefix operators. They must abut their operand with no whitespace:

-x // negation
!a // logical not
!!a // double negation — `!!` lexes as one token that peels into two

Negate/Not bind looser than Power, so a leading minus does not reach inside an exponent:

-x^2 // -(x^2), not (-x)^2

A unary minus applied directly to a number literal folds into the literal rather than becoming a negation:

-2 // the literal -2, not a negation of 2

Unary + is accepted the same way but is the identity: +(2 + 1) is just 2 + 1.

Power: ^ and **​

Power is the tightest operator in the table and is right-associative. ** is an accepted alias for ^ (same table row, same precedence):

x^2 // exponentiation
x**2 // the same
2^3^2 // 2^(3^2) — right-associative

Because Power binds tighter than Multiply/Divide:

x^1/2 // (x^1)/2, not x^(1/2)

Radical signs: √, ∛, ∜​

A radical sign is a prefix operator: √x is sqrt(x), ∛x is root(x, 3), ∜x is root(x, 4). Its operand is what a function call would take — a primary with its postfix clauses and scripts, or another prefix operator — but not an infix operator:

√3 // Sqrt(3)
√(x + 1) // Sqrt(x + 1)
√x² // Sqrt(x^2) — the script belongs to the operand
√f(x) // Sqrt(f(x))
√√2 // Sqrt(Sqrt(2))
-√2 // Negate(Sqrt(2))
√x^2 // Sqrt(x)^2 — `^` does not
√x + 1 // Sqrt(x) + 1

This is how Lean reads √, and it keeps √2x the "√2 times x" every reader expects (see Invisible multiplication). Unlike - and !, a radical sign may be separated from its operand by whitespace (√ 2): it has no infix reading, so there is nothing for the whitespace to disambiguate.

Superscripts and subscripts​

A run of superscript characters written against an operand is its exponent:

x² // x^2
x¹⁰ // x^10
x⁻¹ // x^(-1)
xⁿ⁺¹ // x^(n + 1)
xʸ // x^y
(x + 1)² // (x + 1)^2
f(x)² // f(x)^2
2² // 2^2

The exponent may use the superscript digits ⁰–⁹, the signs ⁺ ⁻, the parentheses ⁽ ⁾, and the superscript Latin letters (ⁱ, ⁿ, ˣ, ʸ, …). A script binds like the postfix factorial — tighter than ^ and than the prefix minus — and composes with ! in written order:

-x² // -(x^2)
2x² // 2·(x^2)
2^x² // 2^(x^2)
x²^3 // (x^2)^3
x²! // (x^2)!
3!² // (3!)^2

A subscript run of letters and digits directly after a name is part of the name (xₙ is the symbol x_n; see Naming). Any other subscript run — one holding a sign or a parenthesis, or one written after a non-symbol operand — is a Subscript:

xₖ₊₁ // Subscript(x, k + 1)
(a + b)ₖ // Subscript(a + b, k)
xₙ² // (x_n)^2

A Subscript whose index evaluates to an integer or a symbol names the same symbol the folded spelling names: with k = 3, xₖ₊₁ evaluates to x_4, and to the value of x_4 if it has one. An index that stays unknown, or is not an integer, keeps the expression symbolic.

Like the factorial, a script must abut its operand: x ² ends the expression at x, and the stray ² is a diagnostic. A run that is not an expression (x⁺) is diagnosed at the run.

Modulo: %​

% is Mod, an infix operator at the multiplicative tier (the same precedence as * and /), left-associative:

a % b // remainder
a + b % c // a + (b % c)
a % b % c // (a % b) % c — left-associative

Factorial: postfix !​

! in postfix position is Factorial. Position disambiguates it from the prefix ! (Not): a ! that abuts the preceding operand is a factorial (x!), while a ! at the start of an operand is Not (!x).

5! // factorial
n! // factorial
!x // prefix not, unchanged

Factorial binds tighter than Power (tier 110 vs. 100), so it reaches inside a Power operand, and a leading minus stays outside it:

2^3! // 2^(3!)
3! ^ 2 // (3!)^2
-3! // -(3!)

It also applies after a parenthesized expression, a call, or an index:

(a + b)! // factorial of the sum
f(x)! // factorial of the result

Like a prefix operator, a postfix ! must abut its operand: x! is a factorial, but x !y is not — the space before ! ends the x expression, leaving !y (a prefix Not) with no separator, which is a diagnostic. Because the lexer maximal-munches a run of operator characters into one token, a ! directly followed by another operator character is not seen as a lone ! (write 3! ^ 2, not 3!^2; x! + 1, not x!+1). The != (NotEqual) and !in (NotElement) operators are unaffected: the lexer keeps != whole and !in is recognized as a compound before the postfix !.

Invisible multiplication​

A number literal immediately followed — with no whitespace — by a symbol or an opening parenthesis is read as an implicit Multiply:

2x // 2 * x
3x^3 // 3·(x^3)
2i // 2 * i, where `i` is the imaginary unit
2(2 + 1) // 2 * (2 + 1)

A number literal under radical signs and superscript exponents is a numeric coefficient too, so it leads (and continues) an invisible multiplication exactly as a bare literal does, and a radical sign may follow a literal:

2√3 // 2 * Sqrt(3)
√2x // Sqrt(2) * x
√2(x + 1) // Sqrt(2) * (x + 1)
2²x // 2^2 * x
2√3x // 2 * Sqrt(3) * x

Only the glyph spellings qualify: Sqrt(2)x and 2^2x are still diagnostics.

Note that a symbol immediately followed by ( is a function call, not an invisible multiplication: x(2+1) calls x, and (a+b)(2+1) calls the value of a+b. Only a number on the left means multiplication. See Calls and Indexing.

Whitespace between the number and the symbol suppresses invisible multiplication and is instead a statement boundary: 2 1/2 is a diagnostic (unexpected-symbol), not 2 * (1/2).

Chained relational operators​

Relational operators (precedence tier 60) are chainable, matching how mathematicians write inequalities: a < b < c means what it looks like, and so does a chain that mixes operators —

a < b <= c

means a < b && b <= c. A mixed chain is rewritten into that pairwise conjunction before it is evaluated, so both kinds of chain have the usual mathematical chained-comparison semantics. Both kinds also short-circuit like &&: the operands are evaluated left to right and evaluation stops at the first adjacent pair that is false, so in a < b < c the operand c is not evaluated when a < b is false.

Logic operators​

  • && (And), || (Or), ! (Not), with the fancy Unicode forms ⋀, ⋁, ¬.
  • && binds tighter than ||, matching the tiers above.
  • Both short-circuit: the operands are evaluated left to right and evaluation stops at the first operand that decides the result — the first false for &&, the first true for ||. The remaining operands do not run, so k <= n && xs[k] > 0 never reads xs[k] when k is out of range, and false && f() never calls f(). Because the written order is meaningful, &&/|| operands are never reordered by canonicalization. The exception is an element-wise application — an operand that is a list of booleans ([true, false] && xs) makes the result a list, cell by cell, and every operand is then evaluated once.

The word forms and, or, and not, and the equivalence infix operator <=>, are reserved but not implemented. The token => is not available as logical implication: it is the mapsto arrow (see Anonymous functions), which is also what separates a match pattern from its result.

Assignment vs. equality​

Three spellings, two meanings:

  • := always assigns. It never compares.
  • == always compares (and === is Same, structural identity). A third comparison tier asks the prover whether the two sides are equal for every value of their free variables: identicallyEqual(sin(t)^2 + cos(t)^2, 1) is True, where == leaves the equation as an inert condition. It is deliberately spelled as a call, never as an operator — the equivalence glyphs ≡, ≢, and ≣ are rejected outright, because their bar counts cross the =-run lengths (≡ has three bars, ≣ four) and a visual transliteration would silently land on the wrong tier.
  • = is positional. It assigns when it is the top-level operator of a statement whose left side is a binding target — a name, or a field/index path rooted at one. Everywhere else it compares.

So a statement assigns:

x = 5
count = count + 1

…while the same = inside any larger expression is an equation, which is what a reader of mathematics expects:

solve(x^2 = 4, x) // Equal — the equation, not an assignment
if a = true { 1 } else { 2 }
[a = 1, b = 2]

This is why = needs no parentheses to be safe in a condition: if a = true cannot silently assign, and the C footgun does not exist in Epsil.

As a comparison, = binds at the relational tier (60) like ==, so if x = 5 && y groups as (x = 5) && y. As an assignment it binds loosest (10), taking the whole right-hand side.

Three consequences worth knowing:

A function head defines the function. As a statement, f(x) = body defines the function f, and f(x) := body is the same definition. This also applies to typed parameters, a return type and literal-pattern clauses (f(0) := 1).

A non-binding left side compares, even as a statement. x^2 = 4 on its own line is the equation, because x^2 is not a name. A bare name always assigns, so write == when you mean the equation:

y == 2 * x + 1 // the equation
y = 2 * x + 1 // assigns to y

A chain is diagnosed. a = b = 5 would assign a the boolean b == 5, which is never what a chained assignment means:

a = b = 5

Write a := b := 5 to chain the assignment, or a = (b = 5) if the comparison really was intended.

A tuple pattern with a bare = is diagnosed. A parenthesized left side is not a binding target, so (a, b) = (b, a) is a comparison of two tuples whose result is discarded — the swap it looks like silently does nothing:

(a, b) = (b, a)

Write (a, b) := (b, a) to destructure, or == if the comparison really was intended. The diagnostic is narrow: it fires only when the left side is shaped exactly like a destructuring pattern (bare names, _, nested tuples), so a genuine tuple equation with computed components — (x + 1, y) = t — stays silent.

An assignment in a condition is a warning. := is unconditional, so it reaches a condition where a bare = no longer can — and Epsil has no if init; cond form, so the assigned value is the test:

if flag := true { 1 } // warning: assign-in-condition

It is a warning rather than an error, since := is the deliberate spelling. It fires only where a value is consumed as a boolean — an if/while condition — not for f(a := 1) or [a := 1], which are unambiguous.

Serialization uses the explicit spellings. An expression written back out by the formatter or serializer always uses := for assignment and == for comparison, never a bare = — so a round-trip is exact regardless of position. = is an input convenience.