FREAKV3 docs freak 0.15.1 (Maverick)

Tasks#

Define a piece of work once, give it a name, pass values in, and get a value back.

A task is FREAK's function. You have written one in nearly every program so far, main, and Chapter 2 added a second. This chapter covers:

  • declaring a task and calling it
  • parameters, and how arguments are checked against them
  • giving a value back, and the rule that every path must do so
  • what happens to a value you pass in
  • main, the exit code, and what the top level of a file is for
  • the pipe operator

Declaring and calling#

A task declaration has a name, a list of parameters in parentheses, and a body in braces.

examples/book_tasks.fk compilesruns
-- Tasks: named pieces of work.
task greet() {
    say "Valkyries, launch."
}

task report(callsign: word, sorties: int) {
    say "{callsign} has flown {sorties} sorties"
}

task add(a: int, b: int) -> int {
    give back a + b
}

task area(width: num, height: num) -> num {
    give back width * height
}

task main() {
    greet()
    report("Valkyrie-1", 12)

    pilot total = add(20, 22)
    say total
    say add(add(1, 2), 3)        -- a call can be an argument of another call

    say area(3, 2.5)             -- the int 3 is accepted where a num is wanted
}
Program output
Valkyries, launch.
Valkyrie-1 has flown 12 sorties
42
6
7.5

greet takes nothing and gives nothing back. report takes two parameters. Each parameter is a name followed by a colon and a type, and the types are required: unlike a pilot, a parameter has no starting value for the compiler to infer from.

You run a task by calling it: its name, then the values for its parameters in parentheses. Those values are the arguments. report("Valkyrie-1", 12) gives the word to callsign and the int to sorties. Arguments are matched to parameters by position, first to first and second to second.

Inside the task, a parameter behaves like a pilot that was created with the argument's value.

Giving a value back#

add and area have something the first two tasks lack: an arrow and a type after the parameter list.

Illustrative snippet; not independently verified. Use the badged executable examples for tested programs.

FREAK
task add(a: int, b: int) -> int {
    give back a + b
}

-> int is the task's return type. It promises that the task produces an int. give back keeps the promise: it evaluates the expression after it, ends the task, and hands the value to the caller.

A call to such a task is itself a value. You can store it in a pilot, print it, or use it as an argument to another call, as add(add(1, 2), 3) does.

A task with no arrow gives nothing back. Its return type is void, and you may write that out as -> void if you prefer; the meaning is the same.

give back ends the task#

give back does not have to be the last line. Wherever it runs, the task ends there.

examples/book_give_back.fk compilesruns
-- `give back` leaves the task at once, from wherever it is.
task rank_for(sorties: int) -> word {
    if sorties >= 50 { give back "veteran" }
    if sorties >= 10 { give back "regular" }
    give back "rookie"
}

-- In a task with no return type, a bare `give back` simply leaves early.
task announce(message: word) {
    if message == "" { give back }
    say message.to_upper()
}

task main() {
    say rank_for(64)
    say rank_for(12)
    say rank_for(3)

    announce("")
    announce("all units, stand by")
}
Program output
veteran
regular
rookie
ALL UNITS, STAND BY

rank_for reads from top to bottom as a series of questions. The first one that is true gives its answer back and the rest are never reached. This style, with the special cases dealt with first and the ordinary case last, is common in FREAK code.

announce shows the same idea in a task that gives nothing back. A give back with no value simply leaves. Here it is used to skip an empty message.

Every path must give back#

If a task promises a value, every way through its body has to end in a give back. This task forgets the case where the condition is false:

diagnostics/v3/book_missing_give_back.fk does not compile
task rank_for(sorties: int) -> word {
    if sorties >= 50 { give back "veteran" }
}

task main() {
    say rank_for(64)
}
Compiler diagnostic
type error: task 'rank_for' may finish without giving back word (line 1)
 --> book_missing_give_back.fk:1:1
1 | task rank_for(sorties: int) -> word {
  | ^

may finish without giving back word is exact. When sorties is 50 or more, the task gives back a word. Otherwise it reaches the closing brace with nothing to give. The compiler checks this by following the structure of the body: an if with an else is enough when both branches give back, and an if alone never is.

Careful

FREAK has no implicit return. In some languages the last expression in a function is its value. Not here: a value on a line by itself is worked out and thrown away. Only give back returns.

The value must be of the promised type#

diagnostics/v3/book_wrong_return_type.fk does not compile
task callsign() -> int {
    give back "Valkyrie-1"
}

task main() {
    say callsign()
}
Compiler diagnostic
type error: task 'callsign' must give back int, got word (line 2)
 --> book_wrong_return_type.fk:2:1
2 |     give back "Valkyrie-1"
  | ^

And a task that promises nothing cannot give back something:

diagnostics/v3/book_void_gives_value.fk does not compile
task greet() {
    give back "Valkyries, launch."
}

task main() {
    greet()
}
Compiler diagnostic
type error: void task 'greet' cannot give back a value (line 2)
 --> book_void_gives_value.fk:2:1
2 |     give back "Valkyries, launch."
  | ^

For the same reason, there is nothing to store from a call to a void task:

diagnostics/v3/book_store_void.fk does not compile
task greet() {
    say "Valkyries, launch."
}

task main() {
    pilot reply = greet()
}
Compiler diagnostic
type error: binding initializer cannot have type void (line 6)
 --> book_store_void.fk:6:1
6 |     pilot reply = greet()
  | ^

Ignoring a result#

The reverse is allowed. You may call a task that gives back a value and not use it. The call is then a statement of its own, and the value is dropped.

examples/book_statements.fk compilesruns
-- A call can stand on its own line; its result is simply dropped.
task log_and_count(message: word) -> int {
    say message
    give back message.length()
}

task main() {
    log_and_count("contact, bearing 270")
    pilot length = log_and_count("engaging")
    say length
}
Program output
contact, bearing 270
engaging
8

Arguments are checked#

The compiler compares every call with the task's declaration. The number of arguments must match:

diagnostics/v3/book_wrong_arg_count.fk does not compile
task add(a: int, b: int) -> int {
    give back a + b
}

task main() {
    say add(20)
}
Compiler diagnostic
type error: call to 'add' expects 2 argument(s), got 1 (line 6)
 --> book_wrong_arg_count.fk:6:1
6 |     say add(20)
  | ^

And each argument must have its parameter's type:

diagnostics/v3/book_wrong_arg_type.fk does not compile
task add(a: int, b: int) -> int {
    give back a + b
}

task main() {
    say add(20, "22")
}
Compiler diagnostic
type error: call to 'add' argument 2 expects int, got word (line 6)
 --> book_wrong_arg_type.fk:6:1
6 |     say add(20, "22")
  | ^

The message counts arguments from 1. argument 2 expects int, got word means the second argument should have been an int and was a word.

The one automatic conversion from Chapter 3 applies here too. In the first listing, area(3, 2.5) passes the int 3 to a num parameter, and that is accepted. The opposite, a num where an int is expected, is an error.

A task gets its own copy#

When you pass a number, a word or a bool to a task, the task receives a copy. Changing the parameter inside the task changes the copy. The caller's pilot is untouched.

examples/book_by_value.fk compilesruns
-- A task receives its own copy of each number, word and bool it is given.
task refuel(level: int) -> int {
    level = level + 40          -- changes the copy
    give back level
}

task main() {
    pilot fuel = 50
    pilot after = refuel(fuel)

    say fuel                    -- still 50
    say after
}
Program output
50
90

This is why refuel has to give back the new level. There is no way for it to reach into main and change fuel.

Note

Shapes and lists, the larger values introduced in Part II, behave differently: a task that receives one can change the caller's copy. Part III explains why, and what ownership has to do with it.

Order does not matter#

A task can be called from a point in the file above its declaration. The compiler reads all the declarations first and only then checks the bodies.

examples/book_order.fk compilesruns
-- Tasks may be declared in any order, and may call themselves.
task main() {
    say factorial(5)
    say is_even(10)
}

task factorial(n: int) -> int {
    if n <= 1 { give back 1 }
    give back n * factorial(n - 1)
}

task is_even(n: int) -> bool {
    if n == 0 { give back true }
    give back is_odd(n - 1)
}

task is_odd(n: int) -> bool {
    if n == 0 { give back false }
    give back is_even(n - 1)
}
Program output
120
true

main comes first here and calls two tasks declared below it. You are free to put the important task at the top of a file and its helpers underneath.

The same listing shows a task calling itself. factorial(5) needs factorial(4), which needs factorial(3), and so on down to 1, where the first line stops the descent. A task that calls itself is recursive, and it must always have a case that does not, or it never ends.

is_even and is_odd call each other. That works for the same reason that calling a task declared later works.

main and the exit code#

Every program reports a number to the operating system when it ends, its exit code. By convention zero means success and anything else means some kind of failure. Scripts and other programs use it to decide what to do next.

When main has no return type, the exit code is zero. When main is declared -> int, the int it gives back is the exit code:

examples/book_exit_code.fk compilesruns
-- When `main` gives back an int, that number is the program's exit code.
task main() -> int {
    say "preflight failed: fuel line"
    give back 3
}
Program output
preflight failed: fuel line
Exit code 3

On Linux and macOS you can see the code of the last command with echo $?. An exit code is a small number. On Linux and macOS only values from 0 to 255 survive the journey to the operating system, so use it for a status, not for a result.

Scripts and the top level#

A file does not have to have a main. Without one, it is a script: the statements at its top level run in order, from the first line to the last.

examples/book_script.fk compilesruns
-- A file with no `main` runs its top-level statements, in order.
pilot squadron = "Valkyries"
say "booting"
say "{squadron} ready"
Program output
booting
Valkyries ready

That is convenient for a few lines of work. Once a file has a main, the program starts there instead, and a statement outside every task would never run. The compiler refuses one rather than skip it quietly:

diagnostics/v3/book_toplevel_main.fk does not compile
-- With a `main`, the top level is for declarations.
pilot squadron = "Valkyries"

say "this top-level statement is not allowed"

task main() {
    say "main sees {squadron}"
}
Compiler diagnostic
type error: executable statement at top level is unsupported; place it inside task main() (line 4)
 --> book_toplevel_main.fk:4:1
4 | say "this top-level statement is not allowed"
  | ^

The top-level pilot is fine, because a pilot is a declaration. The top-level say is a statement, and it is what the message points at.

The rule to take from this: choose one shape per file. Either write a script with no main, or put only declarations at the top level: tasks, pilots, and the shapes of Part II.

Tasks themselves can only be declared at the top level. A task inside a task is an error:

diagnostics/v3/book_nested_task.fk does not compile
task main() {
    task helper() {
        say "nested"
    }
    helper()
}
Compiler diagnostic
type error: declaration is valid only at top level (line 2)
 --> book_nested_task.fk:2:1
2 |     task helper() {
  | ^
type error: unknown callable 'helper' (line 5)
 --> book_nested_task.fk:5:1
5 |     helper()
  | ^

One namespace#

All the tasks in a program share one set of names. Two tasks cannot have the same name, even with different parameters.

The standard library is placed in front of your program before it is compiled, but its tasks do not take names away from you. If you declare a task with the same name as a standard-library task, such as string_reverse, your program calls yours.

The names of the operations built into the compiler, such as ask, are different. They are protected, and declaring a task with one of them is refused with the message conflicts with a compiler builtin.

A pilot also takes a name away from a task while it is visible. Inside a task that declares pilot f = 3, a call f() is refused with local value shadows this name. Give the pilot a different name.

The pipe operator#

Nested calls are read from the inside out, which is the opposite of the order in which they happen. The pipe operator, |>, lets you write a chain of calls in the order they run.

examples/book_pipe.fk compilesruns
-- |> passes the value on its left as the first argument of the call on its right.
task double(n: int) -> int { give back n * 2 }
task offset(n: int, by: int) -> int { give back n + by }

task main() {
    say offset(double(5), 1)          -- nested: read from the inside out
    say 5 |> double() |> offset(1)    -- piped: read from left to right

    say 5 |> double()                 -- double(5)
    say 5 |> offset(3)                -- offset(5, 3)
}
Program output
11
11
10
8

x |> f() means f(x): the value on the left becomes the first argument of the call on the right. If the call has arguments of its own, the piped value goes in front of them, so 5 |> offset(3) means offset(5, 3). Pipes chain from left to right. The first two lines of main compute the same thing; the second says it in the order it happens.

The right side has to be a task named plainly, with its remaining arguments if it has any. You cannot pipe into a method.

In V4#

Tasks are the part of the language where V4 is closest to V3. Declaration, calls, give back, recursion and calling a task declared later all work at the pinned commit.

examples-v4/recursion.fk V4 snapshot 7c9a1f3c27runs
task fib(n: int) -> int {
    if n < 2 { give back n }
    give back fib(n - 1) + fib(n - 2)
}

task main() -> int {
    give back fib(10)
}
Exit code 55
examples-v4/declared_later.fk V4 snapshot 7c9a1f3c27runs
-- `main` calls a task that is declared below it.
task main() -> int {
    give back double(21)
}

task double(n: int) -> int {
    give back n * 2
}
Exit code 42

There is one condition, which Chapter 1 met in main. Every task has to declare what it gives back. A task with no arrow, V3's way of writing a task that gives nothing back, does not build:

examples-v4/task_without_type.fk V4 snapshot 7c9a1f3c27rejected
-- `greet` has no arrow. V3 reads that as a task that gives nothing back.
task greet() {
    say "Valkyrie-1, cleared for launch."
}

task main() -> int {
    greet()
    give back 0
}
V4 diagnostic
v4-native-contract-error=native type not yet supported: unknown

Written with -> void, the same program builds and runs:

examples-v4/task_void.fk V4 snapshot 7c9a1f3c27runs
task greet() -> void {
    say "Valkyrie-1, cleared for launch."
}

task main() -> int {
    greet()
    give back 0
}
Program output
Valkyrie-1, cleared for launch.

Like V3, V4 checks the number and types of arguments and that every path gives back. It words its findings differently:

examples-v4/missing_return.fk V4 snapshot 7c9a1f3c27rejected
task rank_for(sorties: int) -> int {
    if sorties >= 50 { give back 2 }
}

task main() -> int {
    give back rank_for(64)
}
V4 diagnostic
2|0@0:75|missing return|rank_for must give back int on every route
examples-v4/wrong_arg_count.fk V4 snapshot 7c9a1f3c27rejected
task add(a: int, b: int) -> int {
    give back a + b
}

task main() -> int {
    give back add(20)
}
V4 diagnostic
2|0@91:99|call arity mismatch|add expects 2 arguments but got 1
examples-v4/wrong_arg_type.fk V4 snapshot 7c9a1f3c27rejected
task add(a: int, b: int) -> int {
    give back a + b
}

task main() -> int {
    give back add(20, true)
}
V4 diagnostic
2|0@91:105|call argument type mismatch|add argument 2 expects int but got bool

The argument check is looser than V3's in one respect. Chapter 3 showed this commit accepting a num for an int parameter and dropping the fraction.

V4 has two things V3 lacks. A call may name its arguments, and then their order is free:

examples-v4/named_arguments.fk V4 snapshot 7c9a1f3c27runs
-- V4 lets a call name its arguments, in any order.
task offset(n: int, by: int) -> int {
    give back n + by
}

task main() -> int {
    give back offset(by: 1, n: 10)
}
Exit code 11

Named arguments make a call with several parameters of the same type much harder to get wrong. In V3, arguments are positional only.

And a task whose body is a single expression can be written with => in place of braces and give back:

examples-v4/arrow_task.fk V4 snapshot 7c9a1f3c27runs
-- A task whose body is one expression, written with `=>` and no braces.
task square(x: int) -> int => x * x

task main() -> int {
    give back square(3) + square(4)
}
Exit code 25

V4 is also missing one thing V3 has:

examples-v4/pipe_ignored.fk V4 snapshot 7c9a1f3c27runs
-- V3 gives 11 for this: double(5) is 10, offset(10, 1) is 11.
-- This V4 snapshot accepts the pipes and ignores them, leaving 5.
task double(n: int) -> int { give back n * 2 }
task offset(n: int, by: int) -> int { give back n + by }

task main() -> int {
    give back 5 |> double() |> offset(1)
}
Exit code 5
Careful

This is a wrong answer, not an error. At the pinned commit V4 reads the pipe operators and then leaves them out of the program it builds, so the result is the value on the far left. Do not use |> with this V4 commit.

Planned#

Planned

The specification writes the arrow form without a return type, leaving it to be inferred: task square(x: num) => x * x. The pinned V4 commit builds the arrow form only when the type is written out. The specification also allows a body with no braces at all, ended by the word done, and neither compiler builds that.

Planned

Three larger features are specified around tasks and are not in V3: generic tasks, which work for more than one type; closures, which are tasks written inline as values; and visibility, which controls whether a task can be used from another file. Each has a chapter in Part VII.

Summary#

A task is declared with task, a name, typed parameters, and a body. A call passes arguments by position, and the compiler checks their number and types.

A task that gives a value back declares its type after -> and uses give back, which ends the task immediately. Every path through such a task must reach a give back; there is no implicit return. A task without an arrow is void.

Numbers, words and bools are copied into a task. Tasks may be declared in any order and may call themselves. main is where the program starts, and an int it gives back is the exit code. A top-level statement in a file that has a main is a compile error. All your tasks share one set of names. A task of yours may reuse the name of a standard-library task, but not the name of an operation built into the compiler.

Chapter 5 completes the core of the language: making decisions, and repeating work.

Badged executable examples were compiled and run with the released freak 0.15.1 (Maverick). 84/84 examples match their reviewed expected output. Illustrative snippets are labelled separately. Regenerate with python tools/refresh.py.

V4 boxes were checked against the development snapshot 7c9a1f3c27 (2026-10-04); 46/46 V4 examples match their reviewed expectation. Regenerate with python tools/verify_v4.py --checkout <Freak-lang>.