Lowent Manual←↑→

5 Ops — fn and proc

What to know first

chapter 3, The surface · clause order in an op head
chapter 4, Numbers · overflow stops and the treatment is chosen by name

Looking back

In chapter 3′s clause-order table, what do output and effects each come after? Why there?

A. output comes after the data inputs, because the output type may use an input’s type parameter. effects comes after output and before the contracts, because it is where you write what is done with the capabilities received earlier. This chapter covers why the unit of execution that has such a head — the op — splits in two.

The need for this chapter, and its context

The basic unit of a Lowent program is the op, and an op is always either a fn or a proc. That split is the floor on which the effect system of Part IV, the traits of Part VI and the parallelism of Part VII all stand, because a pure op may have its result remembered, be reordered, or be run in parallel. So the two kinds of op come right after numbers, before locals and flow.

By the end of this chapter

You will learn the difference between fn and proc, and what it means that purity is observational (mutation confined inside the op counts as pure). You will pick up how to receive parameters with input clauses and return with output, recursion, and the fact that neg is the only unary arithmetic op. You will also see what gets rejected when a pure op calls an impure one, and when a mut parameter writes back to the caller.

The questions this chapter answers

  1. What happens if a proc actually performs no effect?

5.1 Two kinds#

An op (operation) is a unit of execution with a name and a contract. On the surface it always opens with one of two words.

There is no default. Whether it is a fn or a proc is always written before the name. Builtin operations such as add and len are ops too — ops the language made in advance, called builtin ops.

examples/ch05/kinds.low

module kinds .
rem run: add3 1 2 3
rem run: running_total [4,5,6]
rem run: fact 10

fn add3 input a i32 . input b i32 . input c i32 . output i32 .
do
  return add (add a b) c .
end

rem local mutable state stays inside the op, so a fn may use it
fn running_total input xs slice u8 . output u64 .
do
  var total u64 be 0 .
  for x xs do
    set total (add total (widen u64 x)) .
  end
  return total .
end

fn fact input n u64 . output u64 .
  requires le n 20 .
do
  if le n 1 . do return 1 . end
  return mul n (fact (sub n 1)) .
end

Output

$ lowentc --run add3 kinds.low 1 2 3
add3(1, 2, 3) = 6
$ lowentc --run running_total kinds.low [4,5,6]
running_total([4,5,6]) = 15
  arg0 (written) = [4,5,6]
$ lowentc --run fact kinds.low 10
fact(10) = 3628800

All three ops are fns. add3 adds three arguments, and fact is recursive, calling itself. fact’s requires le n 20 . is there because 21! exceeds u64. Without that contract, fact 21 would stop at a multiplication (chapter 4). With it, execution stops on entry, and where it stopped tells you it was the caller’s fault.

5.2 Purity is decided by observation#

Look at running_total again. Its body has var total and changes it with set. Yet it is a fn. Lowent’s purity is observational: mutating a local variable confined inside the op cannot be seen from outside, so it counts as pure. Even if memory is written at the machine level, it is not an effect if the caller cannot observe it.

On the other hand, a write the caller can see is an effect. A fn that writes through a mut parameter is rejected.

examples/ch05/mutparam.low

module mutparam .
rem expect: E-EFFECT-PURITY

fn zero_first input xs mut slice u64 . output u64 .
do
  set (index xs 0) 0 .
  return 0 .
end

Output

$ lowentc --check mutparam.low
mutparam.low:4:0 E-EFFECT-PURITY: a `fn` WRITES through a `mut` parameter — that write is visible to the CALLER. A fn is an enforced purity contract (SPEC-003 §27): callers may memoise it, reorder it, or elide it. An op that changes caller-owned storage can do none of those. Declare it a `proc`. (Local mutation stays pure: storage confined to the op is not observable — a machine write is not an observable effect. RFC-0057)

As the diagnostic explains, a fn is an enforced purity contract. A caller may remember a fn’s result (memoise), reorder calls, or drop a call whose result is unused. None of that is possible for an op that changes the caller’s storage. So it must be a proc.

examples/ch05/mutproc.low

module mutproc .
rem run: zero_first [7,8,9]

proc zero_first input xs mut slice u8 . output u64 . effects state .
  requires gt (len xs) 0 .
do
  set (index xs 0) 0 .
  return len xs .
end

Output

$ lowentc --run zero_first mutproc.low [7,8,9]
zero_first([0,8,9]) = 3
  arg0 (written) = [0,8,9]

effects state . declares that this op changes state outside itself. The VM’s arg0 (written) = [0,8,9] is the trace — the first element of the slice the caller passed was changed.

A common misconception. A proc is slower than a fn

The kind has nothing to do with speed. Both go down to C the same way. The difference is in what the compiler and the caller may do. Reordering, common-subexpression elimination and memoisation are allowed for a fn and not for a proc. Writing pure work as a proc throws those chances away.

5.3 Don’t write effects on a fn#

A fn’s effects are already none. So adding effects none . is rejected.

examples/ch05/redundant.low

module redundant .
rem expect: E-EFFECT-REDUNDANT

fn square input a u64 . output u64 . effects none .
do
  return mul a a .
end

Output

$ lowentc --check redundant.low
redundant.low:4:0 E-EFFECT-REDUNDANT: a `fn` is pure by contract, so `effects none .` says nothing that the `fn` did not already say — DROP the clause. One meaning must have one spelling (SPEC-002 §2.5). (Keep writing `effects` on `proc`: there it NARROWS, and omitting it means unrestricted.)

Not because it is wrong, but because it would be two spellings of one meaning. On a proc things are different. A proc’s effects clause narrows. A proc without the clause reads as not narrowed — it is assumed able to do input/output, allocation and state. So a proc should carry the clause; doing more than it lists is rejected, and effects it lists but never performs are reported.

Q. What happens if a proc actually performs no effect?

A. Writing effects none . on a proc is allowed. Older code sometimes keeps an op as a proc to emphasise local mutable state or loops. But if no effect is observable, writing it as a fn is the shape this language recommends. Purity has to show in the head for callers and the compiler to use it.

5.4 Effects spread to the caller#

Calling an op adds its effects to the caller’s. When a pure fn calls a proc that performs io, that fn is no longer pure.

examples/ch05/calls.low

module calls .
rem expect: E-EFFECT-CALC

proc say input out cap io . output u64 . effects io .
do
  return write_out out 1 "hi\n" .
end

fn helper input out cap io . output u64 .
do
  return say out .
end

Output

$ lowentc --check calls.low
calls.low:10:1 E-EFFECT-CALC: this fn is declared pure but performs `io` — make it a `proc` with `effects …`, or remove the effect

helper does not print by itself; say does. It is still rejected. Thanks to this rule, the head of an op tells you the effects of every op it calls. There is no way to pass an effect along hidden. Which effect pairs with which capability is covered in chapters 15 and 16.

5.5 Parameters and return#

Here is one op taken apart. What trips up newcomers most is that every clause ends with a full stop. The stop works like the end of a sentence: “this clause is finished”.

fn   add3   input a i32 .   input b i32 .   input c i32 .   output i32 .
│    │      └─ parameter a has type i32 ─┘                   └ type of the returned value
│    └─ name (used when calling)
└─ kind: fn (pure) or proc (may have effects)
do                          ← the body starts here
  return add (add a b) c .  ← compute and return; a statement also ends with a stop
end                         ← the body ends, and so does the whole declaration (no stop after it)

To call an op, write its name followed by the arguments separated by spaces. add3 1 2 3 is one call, and parentheses are used only to put one call inside another as an argument — add3 (add3 1 2 3) 4 5. Parentheses simply mean “this is one value”. There is no C-style add3(1, 2, 3): the comma has no meaning in this language, so it is rejected (see «Common mistakes» below).

A parameter is one input <name> <type> . clause. For several, repeat the clause. The caller supplies arguments in clause order. The returned value is a single output <type> ., and the body returns it with return <expr> ..

fn compare input a i32 . input b i32 . output i32 .
do
  return sub a b .
end

Words in front of the type say ownership, mutability and presence. The ones you meet often are these.

WrittenMeaning
input xs slice u8 .Borrows someone’s bytes for reading
input xs mut slice u8 .Borrows someone’s bytes for writing (must be a proc)
input p ref point . · mut_ref pointBorrows one value for reading · writing (chapter 12)
input o option u64 .A value that may or may not be there (chapter 11)
input out cap io .A capability (chapter 16)
input comptime t type .A type fixed at translation time (chapter 22)

Table 5.1 — Words attached to parameter types

There is one return value. To return several, group them in a struct (chapter 10) or write into a mut slice the caller passed.

5.6 Only neg is unary#

The arithmetic ops add, sub, mul, div and mod take two arguments. The only arithmetic op that takes one is neg, which flips the sign.

examples/ch05/neg.low

module negs .
rem run: flip 5
rem run: dist -3 4

fn flip input a i64 . output i64 .
do
  return neg a .
end

fn dist input a i64 . input b i64 . output i64 .
do
  return abs (sub a b) .
end

Output

$ lowentc --run flip neg.low 5
flip(5) = -5
$ lowentc --run dist neg.low -3 4
dist(-3, 4) = 7

neg is for signed types. On an unsigned value it overflows. abs is the absolute value, and it stops when given a value that cannot be represented as a positive, like the smallest i64. There are no unary operators inside the expr island either; to flip a sign there, write expr 0 - a or call (neg a) in parentheses.

5.7 Modifiers#

Modifiers can go in front of an op head.

unsafe extern proc c_area do
  input k cap c .
  input w i64 .
  input h i64 .
  output i64 .
  effects unsafe .
  link lw_c_area .
end

5.8 Common mistakes#

Almost everyone hits these when writing their first ops. All of them are caught at compile time, so when you see one of these codes you can come back to this section.

Counter-example. Calling C-style, with parentheses and commas

If you know other languages, your fingers type add3(1, 2, 3) before you think.

examples/ch05/mistake_ccall.low

module mistake_ccall .
rem expect: E-VOCAB-REMOVED

fn add3 input a i32 . input b i32 . input c i32 . output i32 .
do
  return add (add a b) c .
end

fn use3 output i32 .
do
  rem ✘ called C-style with parentheses and commas --- a Lowent call is `name arg arg …`
  return add3(1, 2, 3) .
end

Output

$ lowentc --check mistake_ccall.low
12:16 E-VOCAB-REMOVED: `,` (R3) was removed — RFC-0103, 2026-08-27. It opened the NEXT operand of the same form, but nothing used it that way: every `,` in the corpus was a LINE CONTINUATION, and newlines no longer close a form, so continuing a line needs nothing at all. A form is `head operand*` and ends at its closer `.` — just write the operands, on as many lines as you like.
12:19 E-VOCAB-REMOVED: `,` (R3) was removed — RFC-0103, 2026-08-27. It opened the NEXT operand of the same form, but nothing used it that way: every `,` in the corpus was a LINE CONTINUATION, and newlines no longer close a form, so continuing a line needs nothing at all. A form is `head operand*` and ends at its closer `.` — just write the operands, on as many lines as you like.

A Lowent call is prefix notation: the name, then the arguments separated by spaces. The comma was once part of the language and has been removed, hence E-VOCAB-REMOVED. Calls avoid parentheses and commas so that everyone writes the same shape with as few symbols as possible — even on a phone keyboard. The fix: return add3 1 2 3 .

Counter-example. The number of arguments differs from the input clauses

examples/ch05/mistake_arity.low

module mistake_arity .
rem expect: E-IR-ARITY

fn add3 input a i32 . input b i32 . input c i32 . output i32 .
do
  return add (add a b) c .
end

fn use3 output i32 .
do
  rem ✘ three input clauses, but only two arguments
  return add3 1 2 .
end

Output

$ lowentc --check mistake_arity.low
mistake_arity.low:12:0 E-IR-ARITY: `add3` takes 3 arguments but 2 are given here — an op call is `<name> <arg>…` with exactly as many arguments as its `input` clauses

One input clause is one argument. There are no default arguments and no ops that take a varying number of arguments — the head should fix a single calling shape so that readers are never left guessing. The diagnostic tells you how many were expected and how many were given.

Counter-example. Forgetting return on one branch

examples/ch05/mistake_partial.low

module mistake_partial .
rem expect: E-RETURN-PARTIAL

fn sign input a i32 . output i32 .
do
  if gt a 0 . do
    return 1 .
  end
  rem ✘ nothing is returned when a is 0 or less --- one path falls off the end
end

Output

$ lowentc --check mistake_partial.low
mistake_partial.low:4:0 E-RETURN-PARTIAL: this op says it OUTPUTS a value, but some path through its body reaches the end without a `return`. Until now the tool quietly returned 0 there — a value that appears NOWHERE in your source (RFC-0019 G-TOTAL). Give every path a `return`, or say `output void` if it really produces nothing. An exhaustive `match` whose every arm returns counts as returning

An op that declares an output must return a value on every path. The old tool silently returned 0 on the missing path, and that 0 appeared nowhere in the source, so it hid bugs. It is now rejected with E-RETURN-PARTIAL. The fix is to return something for the remaining case after the if — for example, put return 0 . before end.

Counter-example. Naming an op after a builtin

examples/ch05/mistake_builtin.low

module mistake_builtin .
rem expect: E-NAME-BUILTIN

rem ✘ `min` is already a builtin op name --- this declaration could never be called
fn min input a u64 . input b u64 . output u64 .
do
  return add a b .
end

Output

$ lowentc --check mistake_builtin.low
mistake_builtin.low:5:0 E-NAME-BUILTIN: this name is a BUILTIN — the resolver always picks the builtin, so your declaration can never be called: it exists and does not exist. The namespace is FLAT (no shadowing). Rename it

Names live in one flat space, so the same name cannot mean two things. A declaration named after a builtin op (add, min, len, ok …) could never be called, and E-NAME-BUILTIN says so. Local names follow the same rule. Appendix A lists the builtin names.

A common misconception. Calling a fn does something even if you ignore the result

A fn is pure, so throwing its result away is the same as not calling it at all. The compiler is free to delete such a call.

examples/ch05/discard.low

module discard .
rem run: caller

fn twice input a u64 . output u64 .
do
  return mul a 2 .
end

fn caller output u64 .
do
  rem a fn call whose result is not kept --- being pure, it leaves no trace
  twice 5 .
  rem to use the result, bind it to a name
  let t u64 be twice 5 .
  return t .
end

Output

$ lowentc --run caller discard.low
caller() = 10

The first twice 5 . computes a value and leaves no trace. The answer is 10 only because the second line binds the result to t. If a call is meant to do something (print, write), it has to be a proc, and then its effects are written in its head.

5.9 This chapter’s syntax at a glance#

ShapeMeaningWhy
fn f input a T . output R . do … enda pure opthe head alone tells whether results may be cached, reordered or dropped
proc f … effects E . do … endan op that may have effectswhat it does (E) is visible in the head
input x T .one parameterone per clause, so each name and type sits on its own line
output T . · output void .type of the returned value · no returned valueone return value — bundle several in a struct
return e .return a value and finishrequired on every path (E-RETURN-PARTIAL)
f a b ca call — name, then space-separated argumentsprefix notation without parentheses or commas
f (g a) ba call used as an argumentparentheses mean “this is one value”
requires c .a condition the caller must meetblame lands on the caller, at entry (chapter 14)
effects state .writes to the caller’s storagethe head of an op that writes through mut parameters
neg aflip the sign (the only unary arithmetic)a name, so it never gets confused with subtraction
export · extern · unsafeexported · body in C · does unchecked thingswhat an op may do is visible before its name

Table 5.2 — Op syntax — shape · meaning · why it looks this way

Recap

An op is a fn or a proc, and the kind is always written. Purity is observational: mutation confined in the op counts as pure, but a write visible to the caller through a mut parameter needs a proc. A fn carries no effects clause, and a proc’s effects is a narrowing clause. Effects spread to the caller. One clause per parameter, one return value, and the only unary arithmetic op is neg.