2: Language Semantics — Values and Operations
Contents
- 2.10: Constants
- 2.11: Numeric Types and Literals
- 2.12: Operators
- 2.12.1: Compound Assignment
- 2.12.2: Operator Precedence and Associativity
- 2.12.3: The
?Propagation Operator - 2.13: Strings
2.10: Constants
Module-level constants are immutable bindings restricted to compile-time constant expression initializers (see §5.1). Within function bodies, local immutable bindings have no such restriction — they can be initialized with any expression. The UPPER_CASE convention for module-level constants is not enforced by the compiler. PascalCase is the conventional style for type names (structs, enums, interfaces), but this is a style recommendation, not a compiler-enforced rule.
2.11: Numeric Types and Literals
2.11.1: Integer Types
Leaf has three integer types:
| Type | Width | Range |
|---|---|---|
byte |
8-bit unsigned | 0 to 255 |
int |
64-bit signed | −9,223,372,036,854,775,808 to 9,223,372,036,854,775,807 |
uint |
64-bit unsigned | 0 to 18,446,744,073,709,551,615 |
All integer types are fixed-width. There is no arbitrary-precision integer type.
Integer literals:
42 # int
42u # uint (suffix u)
3u # uint
1_000_000 # int with underscores (visual separator)
1_000_000u # uint with underscores
0xFF # int (hexadecimal)
0xFFu # uint (hexadecimal)
0b1010 # int (binary)
0o777 # int (octal)
An unsuffixed integer literal is int by default. The u suffix produces uint. The b suffix produces byte. Hexadecimal (0x), binary (0b), and octal (0o) prefixes work with all integer types (determined by the suffix: u for uint, b for byte, none for int).
Byte literals:
0b # byte (decimal)
255b # byte (decimal)
0xFFb # byte (hexadecimal)
0b11111111b # byte (binary)
0o377b # byte (octal)
The b suffix is required to produce a byte literal. Byte literals must be in the range 0–255; a literal outside this range is a compile error.
Contextual integer literal typing: An unsuffixed non-negative integer literal may be typed as uint instead of int when the surrounding context expects uint. Specifically, an unsuffixed integer literal adopts type uint when it appears in any of these positions:
- As a bracket index expression (e.g.,
items[0]) - As the right-hand side of an assignment or binding with a declared
uinttype (e.g.,n: uint = 42) - As an argument to a parameter of type
uint
This rule applies only to literal expressions — it does not apply to variables, arbitrary expressions, or negative literals. An int variable used as a list index is still a type error; use .toUint() for explicit conversion.
Literal overflow: An integer literal that exceeds the representable range of its type (int.MAX, int.MIN, or uint.MAX) is a compile error. For example, 99999999999999999999999999 exceeds int.MAX and produces a compile-time error. This ensures all literals are validated at parse time.
Overflow behavior: Integer overflow is a panic. Any arithmetic operation on byte, int, or uint that would produce a result outside the type's range terminates the program with a runtime panic. This applies to +, -, *, .pow(), unary - (for int), and explicit conversions.
There is no silent wrapping, no undefined behavior, and no promotion to a wider type. If overflow is expected and desired, use the explicit wrapping and saturating methods described below.
Zero exponentiation: For all numeric types (byte, int, uint, float), 0.pow(0) equals 1 (or 1.0 for float). This follows programming language convention (Rust, Python, JavaScript) rather than mathematical ambiguity.
Division and modulo — truncated division:
Integer division (/) truncates toward zero (rounds toward zero):
7 / 2 # 3
-7 / 2 # -3
7 / -2 # -3
-7 / -2 # 3
Integer modulo (%) satisfies the identity (a / b) * b + (a % b) == a:
7 % 2 # 1
-7 % 2 # -1
7 % -2 # 1
-7 % -2 # -1
4 % -3 # 1
The sign of the result of % always matches the sign of the dividend (left operand). Division or modulo by zero is a panic.
Wrapping and saturating methods:
For cases where overflow is intentional, byte, int, and uint all provide explicit methods that specify overflow behavior. These methods never panic on overflow:
| Method | Description |
|---|---|
.wrappingAdd(other) |
Wraps around on overflow (two's complement for int, modular for uint) |
.wrappingSub(other) |
Wraps around on underflow |
.wrappingMul(other) |
Wraps around on overflow |
.saturatingAdd(other) |
Clamps to MAX or MIN on overflow |
.saturatingSub(other) |
Clamps to MAX or MIN on underflow |
.saturatingMul(other) |
Clamps to MAX or MIN on overflow |
x: int = 9_223_372_036_854_775_807 # int max
x + 1 # panic: integer overflow
x.wrappingAdd(1) # -9_223_372_036_854_775_808 (wraps)
x.saturatingAdd(1) # 9_223_372_036_854_775_807 (clamped)
y: uint = 0u
y - 1u # panic: integer overflow
y.wrappingSub(1u) # 18_446_744_073_709_551_615u (wraps)
y.saturatingSub(1u) # 0u (clamped)
Conversion between int and uint:
There is no implicit conversion between int and uint. Use the explicit conversion methods:
| Method | Description |
|---|---|
int.toUint(self) -> uint |
Panics if the value is negative |
uint.toInt(self) -> int |
Panics if the value exceeds int max |
int.wrappingToUint(self) -> uint |
Reinterprets the bit pattern |
uint.wrappingToInt(self) -> int |
Reinterprets the bit pattern |
Mixed-type arithmetic (int + uint) is a type error. Convert explicitly.
2.11.2: Floating-Point Type
float is an IEEE 754 double-precision (64-bit) floating-point number.
Float literals:
3.14 # float
1_000.50 # float with underscores
1e10 # float (scientific notation)
2.5e-3 # float (scientific notation)
A numeric literal with a decimal point or scientific notation (e/E) is always float. Scientific notation always produces float, even without a decimal point (1e10 is float, not int).
Special values: IEEE 754 defines several special values that can arise at runtime:
| Value | How it arises | Example |
|---|---|---|
| Positive infinity | Overflow, 1.0 / 0.0 |
1e308 * 10.0 |
| Negative infinity | Overflow, -1.0 / 0.0 |
-1e308 * 10.0 |
| NaN (Not a Number) | Invalid operations | 0.0 / 0.0, (-1.0).sqrt() |
Negative zero (-0.0) |
Signed zero | -1.0 * 0.0 |
There are no literal syntax forms for infinity or NaN. They can only be produced by computation. The prelude provides constants to obtain them explicitly — see stdlib/functions.md (float Methods).
Equality and comparison semantics for float:
Float equality and comparison follow IEEE 754 rules, which have important consequences:
NaN != NaN—NaNis not equal to itself.NaN == NaNreturnsfalse. This is standard IEEE 754 behavior.NaNcompared with any value (including itself) via<,>,<=,>=returnsfalse.-0.0 == 0.0returnstrue. Negative zero and positive zero are equal.-0.0 < 0.0returnsfalse.- Infinity compares as expected:
inf > xistruefor all finitex.
float and PartialEq/Eq: float implements PartialEq[float] with IEEE 754 semantics. The == and != operators desugar through PartialEq.eq() like any other type — there is no special compiler intrinsic for float equality. This means:
- You can write
a == bwhereaandbarefloatvalues. NaN == NaNreturnsfalse(IEEE 754 behavior, viaPartialEq.eq()).-0.0 == 0.0returnstrue.- Generic code constrained by
T: PartialEq[T]can usefloat. - Generic code constrained by
T: Eq[T]cannot usefloat—floatdoes not implementEqbecause NaN violates the reflexivity guarantee.
float and Hash: float satisfies Hash. The hash implementation guarantees that 0.0 and -0.0 produce the same hash (since they are equal). All NaN values produce the same hash. Note that float cannot be used as a Map key or Set element because both require Eq[K]/Eq[T], which float does not satisfy. The Hash conformance exists for use in compound types (e.g., a struct containing a float field that implements Eq by excluding NaN values).
float and Comparable: float conforms to Comparable[float], but the comparison operators (<, >, <=, >=) on float operands bypass Comparable desugaring and use native IEEE 754 comparison intrinsics directly when both operands are statically known to be float. This means:
NaN < x,NaN > x,NaN <= x,NaN >= xall returnfalsefor anyx(includingNaNitself). This is standard IEEE 754 behavior.-0.0 < 0.0returnsfalse.
The float.compare method provides a total ordering for use in sorting and generic Comparable contexts: NaN sorts after all other values (including positive infinity), and -0.0 is equal to 0.0. When both operands are NaN, compare returns Ordering.Equal. This total ordering is consistent and deterministic, but it does not match the IEEE 754 partial ordering used by the operators.
Generic contexts: In generic code constrained by T: Comparable[T], comparison operators always desugar through Comparable[T].compare, even when T is instantiated with float. The IEEE 754 bypass applies only when the operands are statically typed as float, not when float flows through a type parameter. This ensures that generic sorting and ordering functions produce consistent, total-ordering results — List[float].sort() deterministically places NaN after all other values.
Testing for special values:
x = 0.0 / 0.0
x.isNan() # true
x.isInfinite() # false
x.isFinite() # false
y = 1.0 / 0.0
y.isNan() # false
y.isInfinite() # true
y.isFinite() # false
z = 3.14
z.isNan() # false
z.isInfinite() # false
z.isFinite() # true
2.11.3: No Implicit Numeric Conversion
There is no implicit conversion between byte, int, uint, and float. Contextual integer literal typing (§2.11.1) is not an implicit conversion — it determines the type of a literal at the point of definition, rather than converting a value from one type to another. Use the explicit conversion methods:
byte.toInt()/byte.toUint()/byte.toFloat()— widenbyteto a larger numeric type. Always lossless.int.toFloat()/uint.toFloat()— convert tofloat. The integer value is represented exactly when it fits within the float's 53-bit mantissa (integers up to 2^53). Larger integers may lose precision.int.toByte()/uint.toByte()— narrow tobyte. Panics if the value is outside 0–255. UsewrappingToByte()for truncation to the low 8 bits without panicking.int.toUint()/uint.toInt()— convert between signed and unsigned. Panics on out-of-range values. UsewrappingToUint()/wrappingToInt()for bit-reinterpretation without panicking.float.round(),float.floor(),float.ceiling()— convert toOption[int]. ReturnsNoneif the float is NaN, infinite, or outside the range ofint.float.roundUint(),float.floorUint(),float.ceilingUint()— convert toOption[uint]. ReturnsNoneif the float is NaN, infinite, negative, or outside the range ofuint.
See stdlib/functions.md for the complete list of primitive type methods.
2.12: Operators
Arithmetic operators work on byte, int, uint, and float:
3 + 3 # addition
4 - 3 # subtraction
4 * 2 # multiplication
4 / 3 # division (integer: truncates toward zero; float: IEEE 754)
4 % 3 # modulo (see below for semantics)
-a # unary negation (int and float only — not valid on uint)
Arithmetic operators require both operands to be the same numeric type. int + float, int + uint, byte + int, and uint + float are all type errors — use explicit conversion.
For byte, int, and uint: overflow on +, -, * is a panic. Division and modulo by zero is a panic. See §2.11.1 for division/modulo semantics and wrapping/saturating alternatives.
For float: arithmetic follows IEEE 754 rules. Overflow produces infinity, invalid operations produce NaN. Float arithmetic never panics.
Exponentiation is a method, not an operator. See .pow() in stdlib/functions.md for semantics, including overflow and negative-exponent behavior.
For float, modulo (%) computes the floating-point remainder using truncating division: a % b equals a - trunc(a / b) * b. The sign of the result matches the sign of the dividend (left operand), consistent with integer modulo.
Unary negation (-) is valid on int and float, but not on byte or uint (applying - to a byte or uint value is a type error). For int, negating int.MIN (−2^63) is a panic because the result (2^63) does not fit in a signed 64-bit integer.
String concatenation uses +:
"hello" + " " + "world" # "hello world"
+ on strings performs concatenation. This is the only non-numeric use of +.
Comparison operators:
a == b # equality
a != b # inequality
a >= b # greater than or equal
a <= b # less than or equal
a > b # greater than
a < b # less than
== and != work on any type that implements the PartialEq[T] interface. The built-in types bool, byte, int, uint, float, and str all implement PartialEq. For float, PartialEq follows IEEE 754 semantics (NaN != NaN, -0.0 == 0.0). See §2.11.2 and stdlib/interfaces.md (PartialEq[T]).
Ordering operators (<, >, <=, >=) work on any type that satisfies the Comparable[T] interface. The built-in types byte, int, uint, float, and str all support these operators. For byte, int, uint, and str, the operators desugar through the Comparable interface (see stdlib/interfaces.md Comparable[T]). For float, when both operands are statically typed as float, the operators use native IEEE 754 comparison intrinsics and do not desugar through compare — see §2.11.2 for details. In generic code where float flows through a type parameter T: Comparable[T], operators desugar through compare like any other type. User-defined types can support ordering by providing a compare method.
Canonical interface desugaring. Operators always desugar through their canonical interface, regardless of method name collisions or qualified method disambiguation. Specifically:
==and!=always desugar throughPartialEq[T].eq<,>,<=,>=always desugar throughComparable[T].compare
If a struct implements multiple interfaces that define methods with the same name (e.g., both PartialEq[T].eq and a package-internal InternalEq[T].eq), the operator selects the canonical interface's method — not by visibility or precedence, but by fixed association. This means a == b is never ambiguous, even in contexts where an unqualified a.eq(b) call would be a compile error due to name collision. See lang/structs/core.md §4.5 for qualified method call rules.
Logical operators:
a && b # logical and
a || b # logical or
!a # logical not
Logical operators require bool operands and produce bool.
Bitwise operators:
a & b # bitwise and
a | b # bitwise or
a ^ b # xor
~a # bitwise not
a >> b # right shift
a << b # left shift
Bitwise operators work on byte, int, and uint values. For &, |, ^, and ~, both operands must be the same type. Bitwise operations on int use two's complement representation. For example, ~0 equals -1, and ~(-1) equals 0. Bitwise operations on byte and uint treat all bits as unsigned magnitude.
Shift behavior:
- The right operand (shift amount) must be
uint. Using anintshift amount is a type error — this eliminates the possibility of a negative shift at the type level. - Shift amounts ≥ the bit width of the left operand cause a panic (out-of-range behavior). The bit width is 8 for
byte, 64 forintanduint. - Right shift (
>>) onintis arithmetic (sign-extending): the sign bit is replicated to fill vacated positions. Right shift onbyteanduintis logical (zero-filling). - Left shift (
<<) is logical forbyte,int, anduint(vacated bits are filled with zeros).
Pipe operator:
x |> f # desugars to f(x)
The pipe operator |> applies the left operand as the sole argument to the right operand. expr |> f desugars to f(expr). The right-hand side must evaluate to a callable value (a function, closure, or any expression of function type fn(T) -> U).
Pipe is left-associative, so chains evaluate left to right:
x |> f |> g |> h # h(g(f(x)))
Pipe enables left-to-right data pipelines with free functions, complementing the method-chain syntax available for type methods:
doubledEvens = items.map(fn(x) x * 2).filter(fn(x) x > 5) |> toList
The pipe operator works with generic functions — type arguments are inferred from the piped value:
names = items.map(fn(x) x.name) |> toList # T inferred as str
The ? propagation operator has higher precedence than |>, so ? applies to the piped call's result naturally:
fn process(path: str) -> Result[List[str], IoError]
content = io.readFile(path)?
return content.split("\n").filter(fn(s) !s.isEmpty()) |> toList |> Ok
end
No operator overloading. Leaf does not support user-defined operator overloading. The behavior of every operator is fixed by the language:
- Arithmetic (
+,-,*,/,%, unary-):byte,int,uint,floatonly. - String concatenation (
+):stronly. - Comparison (
==,!=,<,>,<=,>=): resolved viaPartialEqandComparableinterfaces (built-in compiler behavior, not general overloading). - Logical (
&&,||,!):boolonly. - Bitwise (
&,|,^,~,<<,>>):byte,int, anduintonly. - Pipe (
|>): applies left operand as sole argument to right operand.
Using + on two List[T] values, or any other non-permitted type, is a type error. Use explicit methods instead (e.g., list.concat(other)).
2.12.1: Compound Assignment
Compound assignment operators (+=, -=, *=, /=, %=, &=, |=, ^=, >>=, <<=) are syntactic sugar for a read, operation, and reassignment:
x += 1 # desugars to: x = x + 1
Compound assignment is only valid on mut bindings.
2.12.2: Operator Precedence and Associativity
The following table lists all operators and expression forms from highest (tightest binding) to lowest precedence. Operators at the same precedence level are grouped by the associativity shown. This table is based on Rust's precedence rules, adapted for Leaf's operator set.
| Precedence | Operator / Expression | Associativity | Description |
|---|---|---|---|
| 1 | .field, .method(), Type.new(), Enum.Variant |
left to right | Field access, method calls, static methods, enum variants |
| 2 | f(), x[i], F[T] |
left to right | Function calls, bracket indexing / type arguments (§2.7) |
| 3 | x? |
— (postfix) | Error/None propagation (see §2.12.3) |
| 4 | -x, !x, ~x |
— (unary, prefix) | Unary negation, logical not, bitwise not |
| 5 | *, /, % |
left to right | Multiplication, division, modulo |
| 6 | +, - |
left to right | Addition / string concatenation, subtraction |
| 7 | <<, >> |
left to right | Bit shifts |
| 8 | & |
left to right | Bitwise AND |
| 9 | ^ |
left to right | Bitwise XOR |
| 10 | | |
left to right | Bitwise OR |
| 11 | ==, !=, <, >, <=, >=, matches |
— (see below) | Comparison, equality, pattern test |
| 12 | && |
left to right | Logical AND (short-circuiting) |
| 13 | || |
left to right | Logical OR (short-circuiting) |
| 14 | \|> |
left to right | Pipe (function application) |
| 15 | yield |
— (prefix) | Yield expression |
Notes:
- Comparison and equality operators are non-associative: Chaining comparisons like
a < b < cora == b == cis a compile error. The parser or type checker must reject these forms. Error message should suggest: "chained comparisons are not allowed; usea < b && b < cinstead". matchesis a non-associative pattern-test operator at comparison precedence.expr matches patternevaluates tobool. When used as the direct condition ofif,elseif, orwhile, named pattern bindings are introduced into the guarded body. In other positions, only non-binding patterns are allowed (_, literals, bare variant names). See §3.1 intypes.mdfor full binding scope rules.?is postfix with very high precedence:getValue()?applies?to the result ofgetValue().foo.bar()?.baz()applies?tofoo.bar(), then calls.baz()on the unwrapped value. See §2.12.3.- Logical
&&binds tighter than||:a || b && cparses asa || (b && c), matching the convention from C, Java, and Rust. |>(pipe) is left-associative with precedence just aboveyield.x |> f |> gparses as(x |> f) |> g, which desugars tog(f(x)).a + b |> fparses as(a + b) |> f, which desugars tof(a + b).yieldis a prefix expression at the lowest precedence.yield a + bparses asyield (a + b).yieldevaluates to the value of typeNpassed by the caller on resume (see §3.3 intypes.md).yieldis only valid insidegen fnbodies.- Assignment is a statement, not an expression.
=and compound assignment operators (+=,-=, etc.) appear only in statement grammar productions (assignment,compound_assignment). They do not appear in the expression grammar and cannot be nested inside expressions. Chained assignment likex = y = 5is not valid Leaf. - Parentheses override any precedence:
(a + b) * c.
Examples:
2 + 3 * 4 # 14, not 20 — multiplication binds tighter
a & b | c # (a & b) | c — bitwise AND binds tighter than OR
a == b && c != d # (a == b) && (c != d)
!x && y || z # ((!x) && y) || z
x.foo() + y[0] * 3 # (x.foo()) + ((y[0]) * 3)
getData()?.len() # (getData()?).len() — propagate then access
2.12.3: The ? Propagation Operator
The ? operator provides ergonomic early return for Result[T, E] and Option[T] values. It is a postfix operator that unwraps the success case or immediately returns the failure case from the enclosing function.
On Result[T, E]:
value = expr?
desugars to:
value = match expr
Ok(v) then v
Err(e) then return Err(e)
end
If expr evaluates to Ok(v), ? unwraps it and the expression evaluates to v (of type T). If expr evaluates to Err(e), ? immediately returns Err(e) from the enclosing function.
On Option[T]:
value = expr?
desugars to:
value = match expr
Some(v) then v
None then return None
end
If expr evaluates to Some(v), ? unwraps it and the expression evaluates to v (of type T). If expr evaluates to None, ? immediately returns None from the enclosing function.
Return type constraint: The ? operator can only be used inside a function whose return type is compatible with the propagated value:
- Using
?onResult[T, E_inner]requires the enclosing function to returnResult[U, E_outer]for someU, whereE_inneris assignable toE_outer. BecauseResult[out T, out E]is covariant inE, the desugaredreturn Err(e)is valid whenever this assignability holds — the error types need not be identical. - Using
?onOption[T]requires the enclosing function to returnOption[U]for someU. (SinceNonehas typeOption[never]andOption[out T]is covariant, this is satisfied by anyOptionreturn type.)
Using ? in a function with an incompatible return type is a compile error.
Examples:
# Without ? — verbose
fn process(input: str) -> Result[int, str]
result = parseInt(input)
value = match result
Ok(v) then v
Err(e) then return Err(e)
end
return Ok(value * 2)
end
# With ? — concise
fn process(input: str) -> Result[int, str]
value = parseInt(input)?
return Ok(value * 2)
end
Chaining: ? can be chained across multiple fallible calls:
fn loadConfig(path: str) -> Result[Config, str]
content = readFile(path)?
parsed = parseJson(content)?
config = validate(parsed)?
return Ok(config)
end
With method calls: Because ? has higher precedence than most operators but lower than field access and function calls, it integrates naturally with method chains:
fn getName(id: int) -> Result[str, str]
user = findUser(id)?
name = user.profile.get("name")?
return Ok(name)
end
With Option:
fn firstChar(s: str?) -> str?
value = s? # unwrap Option or return None
if value.isEmpty()
return None
end
return value.slice(0, 1)
end
Cross-type error propagation: Because the constraint is assignability (not equality), ? works when the inner error type is a subtype of the outer error type:
# parseInt returns Result[int, str]
# str implements ToString, so str is assignable to ToString
fn flexible(input: str) -> Result[int, ToString]
value = parseInt(input)? # str error is assignable to ToString
return Ok(value * 2)
end
? is only valid inside a function body — there must be an enclosing function to return from. Using ? outside a function body is a compile error.
? in gen fn bodies. In a generator function, return targets the generator's R type parameter, not the full Generator[Y, R, N] return type (see types.md §3.3). Since ? desugars to return Err(e) or return None, it follows that ? checks compatibility against R. For example, using ? on Result[T, E] in a gen fn requires R to be Result[U, E_outer] where E is assignable to E_outer.
Disambiguation: ? in types vs expressions. The ? character has two distinct roles in Leaf. In type context, T? is sugar for Option[T] (e.g., int?, str?). In expression context, expr? is the propagation operator described here. The parser always knows which context it is in — there is no ambiguity.
2.13: Strings
Strings are UTF-8 encoded and immutable. All string literals in source code and runtime string values use UTF-8 encoding. Source files must also be UTF-8 encoded. String operations always produce new string values — there is no way to modify a string in place. Strings provide both grapheme-cluster-level access (charAt, slice, charLength) and raw byte-level access (bytes, toBuffer) — see stdlib/functions.md for the full method listing.
Delimiters: Only double quotes (") are valid string delimiters. Single quotes and backticks are not valid.
Escape sequences:
\n— newline\t— tab\\— literal backslash\"— literal double quote\{— literal opening brace (prevents interpolation)\}— literal closing brace\uXXXX— Unicode code point (exactly 4 hex digits, e.g.,\u2764for ❤), encoded as UTF-8\u{XXXXXX}— Unicode code point (1-6 hex digits, e.g.,\u{1F600}for 😀), supports full Unicode range U+000000 to U+10FFFF
heart = "\u2764"
rocket = "\u{1F680}" # 🚀 (non-BMP character)
The \uXXXX form is limited to the Basic Multilingual Plane (U+0000 to U+FFFF). The \u{XXXXXX} form supports the full Unicode range, including emoji, historic scripts, rare CJK ideographs, and mathematical symbols outside the BMP. Leading zeros are optional: \u{1F600} and \u{01F600} are equivalent.
String interpolation uses {} inside double-quoted strings:
name = "world"
greeting = "Hello {name}"
result = "1 + 2 = {1 + 2}"
complex = "value: {someFunction(x)}"
Nested strings in interpolation. The lexer tracks {/} brace depth inside interpolation expressions, so string literals (including further interpolation) are permitted within {...}:
greeting = "Hello {greet("world")}"
nested = "result: {format("{x}")}"
The inner " characters delimit string literals within the interpolation expression and do not terminate the outer string. Brace-depth tracking ensures that } characters inside nested strings or nested interpolation are not mistaken for the end of the enclosing interpolation expression.
No comments inside interpolation. Inside string interpolation expressions, # does not start a comment — it is a compile-time error. The full expression must be written without comments. This avoids ambiguity where a # would consume the closing } as part of a comment, leaving the interpolation unclosed.
Interpolation requires ToString. An expression inside {...} in a string literal must have a type that satisfies the ToString interface (see stdlib/interfaces.md ToString):
interface ToString
fn toString(self) -> str
end
The built-in types byte, int, uint, float, str, and bool all satisfy ToString. User-defined structs can participate in string interpolation by implementing a toString method.
Exception: enums. Enum types satisfy ToString automatically — simple enums (no associated data) unconditionally, and enums with associated data when all associated data types satisfy ToString. The compiler generates a string representation that includes the variant name and, for variants with associated data, the string representation of the contained values. The output format is unspecified, unstable across compiler releases, and intended only as a developer-facing diagnostic aid — not for user-facing display or serialization.
Multiline strings use triple double quotes:
text = """
This is a
multiline string.
"""
Triple-quoted strings preserve their content exactly as written between the opening """ and closing """ delimiters. There is no automatic indentation stripping, no trimming of leading or trailing newlines, and no special whitespace handling of any kind. The string value includes every character between the delimiters verbatim.
# The value of `text` above starts with a newline (after the opening """),
# contains four leading spaces on each line, and ends with a newline
# (before the closing """).
indented = """
hello
world
"""
# indented == "\n hello\n world\n"
# To avoid a leading newline, start content on the same line:
compact = """hello
world"""
# compact == "hello\nworld"
Escape sequences and string interpolation work inside triple-quoted strings, the same as in regular strings.
String equality: String equality (==) performs byte-by-byte comparison. Strings are not normalized before comparison. Two strings are equal if and only if they have identical byte sequences. Canonically equivalent Unicode strings with different normalization forms (e.g., NFC vs NFD) are not equal. For example, the string "é" represented as U+00E9 (single code point) and U+0065 U+0301 (two code points) have identical visual appearance but are unequal under == because their UTF-8 byte sequences differ.