Expressions
Khora is expression-oriented. Literals, calls, blocks, conditionals, matches, pipelines, lambdas, handlers, and control-flow forms all appear in expression position according to their type.
Literals
Section titled “Literals”421_000_0003.146.02e2319.99dtruefalse"hello"`multiline text`See Lexical structure for exact literal and interpolation forms.
Paths and names
Section titled “Paths and names”valueapp::model::UserResult::Ok:: is compile-time namespacing for modules, types, constructors, and associated items.
parse(input)connect(host, port)Arguments are positional and may have a trailing comma.
Labeled arguments
Section titled “Labeled arguments”At a call to a named function, an argument may be written name: value, where
name is the name the function’s declaration gives that parameter. The label
is checked and changes nothing else: arguments are still matched to parameters
by position, and still evaluated left to right in the order written.
fn reply(connection: Connection, response: String, keep_alive: Bool) -> String { response}
reply(connection, "ok", keep_alive: false)reply(connection: c, response: "ok", keep_alive: true)- Any argument may be labeled, or none. Labeled and unlabeled arguments mix freely in any combination, because a label never moves anything. A label is never required.
- A label must name the parameter at its position.
reply(c, keep_alive: true, response: "ok")is refused even though both names exist, and the error says which position each name has. - A label naming no parameter is refused. So is a label written twice, since
it cannot match both positions, and a label on a parameter declared
_._:is not a label. - Methods. In
Type::f(x, a)the receiver is parameter 1 and is not labeled;ais parameter 2 and may be. The same holds in the second form,x.f(a).self:is refused: the receiver is written one way. - Pipes.
x |> f(b: 1)putsxin parameter 1, or in the_slot, andb:is checked against the parameter it lands in. - Traits. A trait method’s labels are the trait’s parameter names, however
the method is reached:
Trait::m(x, ..),Type::m(x, ..)orx.m(..). Animplmay name its parameters differently; those names are local to its body. - Constructors. A case with a named payload,
| A(v: Int, w: Bool), takes labels exactly as a function does. A positional payload,| B(Int), takes none. Patterns are unchanged. - Calls through a value take no labels. A function type such as
(Int, Bool) -> ()names no parameters, so a label at a call through a local, a parameter, a field, a closure or an effect operation is refused. extern fndeclarations are labeled by their declared names. Labels do not reach C.
There are no default arguments, and a label cannot skip or reorder one. A parameter’s name is part of a public function’s interface once any caller labels it: see Compatibility.
Field projection and method calls
Section titled “Field projection and method calls”user.nameresponse.statusUser::display_name(user). reads a field of a runtime value. It is distinct from :: path lookup.
A method is called through its owner: User::display_name(user), with the
receiver as the first argument. The owner is the type that declares the
method, or the trait for a trait’s method: Show::show(n), and inside
fn f<A: Show>(x: A), Show::show(x), since a type parameter has no A:: to
write. The qualified call names where the function lives, it is the form |>
composes with (name |> String::trim), and it is the one that settles a name
two traits share.
user.display_name() is the second form of the same call, and it compiles:
the receiver is found first and the method looked up on its type, a field
holding a function winning over a method of the same name. Both forms
evaluate the receiver first and then the arguments, left to right. The
idiomatic lint group reports the second form as method-call, and
khora check --fix rewrites it to the first; see
Lints.
Record literals
Section titled “Record literals”{ id: 42, name: "Ada",}A braced form beginning with name: is a record literal; an ordinary braced
sequence is a block.
{} in expression position is a record literal, not an empty block. It is
the value of a record type declared with no fields — pub type Nothing = {}; —
and it only checks where such a type is expected:
pub type Nothing = {};
let nothing: Nothing = {};Everywhere else it is refused, and the message says so and says what to write instead:
error: `{}` is an empty record literal, and no record type here is declared with no fields. Write `()` for a block that does nothing --> src/main.kh:9:21 |9 | Option::None => {}, | ^^The place this bites is a match arm that should do nothing. => is followed
by an expression, so Option::None => {} is a record literal and is refused.
Write Option::None => () for an arm that produces unit. A braced block after
=> is fine as long as it is not empty: Option::None => { retire(); }.
Record update
Section titled “Record update”{ ..base, field: value } builds a new record from an existing one. Every
field not named comes from base:
let renamed = { ..user, name: "Grace" };It is a new record: user is unchanged. What the syntax saves is writing
out the fields that do not change, which starts to matter at more than a few:
fn applied(counts: Counts, event: Event) -> Counts { match event { Event::Created => { ..counts, created: counts.created + 1 }, Event::Deleted => { ..counts, deleted: counts.deleted + 1 }, Event::Expired => { ..counts, expired: counts.expired + 1 }, }}The base comes first and appears once. A field named twice is an error rather
than last-one-wins, so is a field the base’s type does not have, and so is a
base that is not a record. { ..base } with nothing after it is base.
The base’s type has to be a record the checker can name. A type
parameter is not one, whatever a caller instantiates it at, so fn f<A>(x: A) -> A { { ..x, n: 1 } } is refused. A lambda’s parameter can be the base
without an annotation when a call fixes its type: let g = fn c => { ..c, created: 0 }; followed by g(counts) is checked as an update of Counts.
When nothing ever fixes it, the update asks for an annotation, because there
is no record to check the fields against. From outside a record’s module,
an update is subject to field visibility like
any other way of building one.
A mut field is the other way to do this. The update
produces a new value; assigning a mut field changes the one already held.
Reach for the update where the old value still matters, and for mut where it
does not.
Tuple and unit expressions
Section titled “Tuple and unit expressions”()(1, "one")(1,)Parentheses without a comma group an expression:
(value + 1)List literals
Section titled “List literals”[][1, 2, 3][ "Ada", "Grace",]Elements that come and go. Inside [..], three forms stand for any number of elements:
[header, if debug => trace, for r in rows => render(r), ..footer]if c => x is x when c holds, and nothing otherwise; else gives the other case, and else if chains. for p in xs => e is one e per item. ..xs is every element of the list xs. They nest, as in [for x in xs => if x != 2 => x], and they are evaluated left to right, each condition and iterable once. An else belongs to the nearest if, so [if a => if b => x else y] is y when a holds and b does not.
=> is what makes them elements. [if c { x } else { y }] is a one-element list holding the value of an if expression, and [if c { x }] is a type error unless x is (): “an if without else must produce ()”, followed by “inside [..], an element that is only sometimes there is written if c => x”.
A for element needs Step and Iterator in scope, as a for loop does, and it is a cancellation point like one. It cannot be left with break or continue; filter with if instead. return and ! work inside elements as they do anywhere in the function, and leave it with the elements made so far freed.
Only List has a literal. A match that makes zero or more elements is written as a spread: ..match o { Option::Some(v) => [v], Option::None => [] }.
Cost. The literal is built front to back and reversed once, so it is linear in the elements it makes, and allocates what a for that pushes and then calls reverse would. A trailing ..xs is shared rather than copied; a spread anywhere else walks its list.
Integer division and remainder
Section titled “Integer division and remainder”/ truncates toward zero and % takes the sign of the dividend, which is what C, Rust, Go and the hardware instruction all do:
(0 - 7) / 2 // -3, not -47 / (0 - 2) // -3(0 - 7) / (0 - 2) // 3
(0 - 7) % 2 // -17 % (0 - 2) // 1(0 - 7) % (0 - 2) // -1The two agree, so a == (a / b) * b + (a % b) holds for every pair that does not trap. Float::to_int truncates toward zero for the same reason, and says so.
Both trap on a zero divisor rather than answering anything — see Traps. For a quotient that rounds rather than truncating, do the rounding in Decimal, where the mode is a parameter and not a convention.
Blocks
Section titled “Blocks”{ let subtotal = 40; let tax = 2; subtotal + tax}The final expression without a semicolon is the block value. Statements before it are evaluated in order.
Local bindings
Section titled “Local bindings”let value = compute();let value: Int = compute();let mut count = 0;let (left, right) = pair;General form:
let mut? Pattern (: Type)? = Expr ;A binding is immutable unless it says mut, and a plain let cannot be
assigned to later. mut is fiber-local mutation only: state that several
fibers evolve is a Shared boundary instead, and
Sharing says why.
let is local. Module-level named expressions use const; see Declarations.
Assignment
Section titled “Assignment”count = count + 1Assignment is an expression of type () and is right-associative. The target must be writable, such as a let mut binding or a mutable record field.
Lambdas
Section titled “Lambdas”Single parameter:
fn value => value * 2Ignored parameter:
fn _ => fixed_valueSeveral or annotated parameters:
fn (left: Int, right: Int) => left + rightBlock body:
fn value => { let doubled = value * 2; doubled + 1}Pipeline |>
Section titled “Pipeline |>”First-argument insertion:
value |> transform(a, b)is equivalent to:
transform(value, a, b)One _ placeholder selects another argument position:
value |> transform(a, _, b)A stage may contain at most one placeholder.
Bare unary function:
value |> normalize |> validateFallible stage:
value |> parse! |> validate(config)!A pipeline introduces no second error model: ! still marks the exact call
where a typed failure may leave the function, and catch still applies either
to one stage or to the parenthesized pipeline as a whole.
let user = (raw |> parse! |> validate!) catch { ParseError::Invalid(message) => User::invalid(message),};Flow lambda ||>
Section titled “Flow lambda ||>”||> starts a unary anonymous pipeline:
||> normalize|> validate!|> persist!For example:
items |> List::map( ||> normalize |> validate!)It is equivalent in shape to fn value => value |> ..., and infers its
effects, failures and captures the same way.
Following |> stages belong to the flow lambda until grouping ends, so piping
the function value itself takes parentheses:
(||> normalize) |> apply_twice||> is always unary. An anonymous function of several parameters is fn.
A named function needs neither: items |> List::map(normalize).
Reach for a pipeline when the value moving through the expression is the thing to follow, and for an ordinary call when the operation itself is the point of the line.
Operators and precedence
Section titled “Operators and precedence”From loosest to tightest:
- assignment
=— right-associative - pipeline
|>— left-associative - logical OR
|| - logical AND
&& - comparisons
== != < > <= >= - addition/subtraction
+ -—+also joins twoStrings, which is the only concatenation operator there is; there is no++ - multiplication/division/remainder
* / % - prefix negation
-and boolean not! - postfix call, field access, failure
!,catch, andwith
Examples:
value + 1 |> doubleready && count > 0!enabled-totalPrefix !value is boolean negation. Postfix call()! marks failure propagation; position disambiguates them.
Failure postfix !
Section titled “Failure postfix !”load_user(id)!The inner call keeps its normal value type while its declared failure row is allowed to leave the current computation. See Failures.
load_user(id)! catch { UserError::NotFound(_) => User::guest(), UserError::Unavailable(reason) => User::offline(reason),}catch is postfix on the expression whose typed failures it handles, and that
expression still needs its !. The mark is what lets the failure leave the
call; catch only says what to do with it once it has. Omitting it is refused:
error: `load_user` can leave this function, so the call needs `!`: write `load_user(..)!`Postfix capability installation
Section titled “Postfix capability installation”load_user(id)! with { store: test_store,}A named context can be supplied the same way:
load_user(id)! with Productionwith overrides:
load_user(id)! with Production { store: test_store,}See Capabilities.
Handler expressions
Section titled “Handler expressions”handler for Clock { now: fn () => fixed_instant,}A handler expression produces a value implementing the named effect.
with blocks
Section titled “with blocks”with { clock: fixed_clock, store: test_store,} { run_job()!}Named context:
with Production { run_server()!}Control-flow expressions
Section titled “Control-flow expressions”The following forms are expressions or block-like expressions and have dedicated rules in Control flow:
if condition { a } else { b }match value { Pattern => result, }while condition { body; }for pattern in iterable { body; }loop { body; }breakbreak valuecontinuereturnreturn valueraise errorPatterns used by match, for, destructuring let, and catch are listed in Patterns.
Khora 0.4.0built frombd964738afb6on 2026-10-17 20:41ZVersioningSecurityContributingReleasesSource