Tasks
Keel supports composing programs from multiple files using Task.run "file" expressions. All file reading, parsing, and inlining happens at compile time — there are no runtime file operations.
Running a Task
Use Task.run "path" to run another file as a task. The expression returns a record of exposed values, which you destructure with let:
let x = 5
let { result } = Task.run "compute.kl" (x)
result -- value computed by compute.kl
The compiler reads the target file, type-checks it, and inlines its code at that point. The Task.run expression returns a record whose fields are the callee's exposed variables.
The Task Definition
The target file declares its inputs and outputs with a Task.define declaration:
-- compute.kl
Task.define (x : Int) -> (result : Int)
let result = x * 2
(x : Int, ...)declares typed input parameters — what the caller must pass-> (result : Int, ...)declares typed output variables — the fields of the returned record
Passing Variables
Task.run supports three forms for passing variables:
| Form | Meaning | Example |
|---|---|---|
(x, y) | Named variables | Task.run "f.kl" (x, y) |
(..) | All variables | Task.run "f.kl" (..) |
() | No variables | Task.run "f.kl" () |
The argument list is optional when no variables need to be passed — Task.run "f.kl" is equivalent to Task.run "f.kl" ().
Receiving Values
The Task.run expression returns a record, so you use record destructuring to receive values:
-- Destructure specific fields
let { result } = Task.run "compute.kl" (x)
-- Destructure multiple fields
let { sum, product } = Task.run "math.kl" (a, b)
-- Ignore the result
let _ = Task.run "side_effect.kl"
Multiple Variables
Pass and receive multiple variables:
-- math.kl
Task.define (a : Int, b : Int) -> (sum : Int, product : Int)
let sum = a + b
let product = a * b
let a = 3
let b = 7
let { sum, product } = Task.run "math.kl" (a, b)
sum -- 10
product -- 21
Variable Flow
Variables move between files in a controlled, explicit way:
- The caller passes variables:
Task.run "file.kl" (x, y)orTask.run "file.kl" (..) - The callee declares typed parameters:
Task.define (x : Int, y : Int) -> ... - The callee declares typed outputs:
-> (result : Int) - The caller destructures the returned record:
let { result } = Task.run ...
Variables not passed are invisible to the callee. Variables not in the output declaration are not returned. This keeps file boundaries explicit.
Mutation Propagation
To propagate a re-bound variable back to the caller, mark it mut in the callee's output declaration:
-- double.kl
Task.define (x : Int) -> (result : Int, mut x : Int)
let x = x * 2
let result = x + 1
let x = 5
let { result, x } = Task.run "double.kl" (x)
x -- 10 (mutated because of 'mut' in output)
result -- 11
Without mut, re-binding a parameter inside the callee does not affect the caller's variable:
-- no_propagation.kl
Task.define (x : Int) -> (result : Int)
let x = x * 2 -- local change only
let result = x + 1
let x = 5
let { result } = Task.run "no_propagation.kl" (x)
x -- 5 (unchanged — no 'mut' in output)
result -- 11
The mut variable must be one of the task's input parameters — marking a locally created variable as mut is a compile error.
Output-Only Tasks
A callee doesn't need input parameters. It can simply compute values and expose them:
-- constants.kl
Task.define () -> (answer : Int)
let answer = 7 * 6
let { answer } = Task.run "constants.kl"
answer -- 42
Working with Strings
Tasks work with all Keel types:
-- greet.kl
Task.define (name : String) -> (greeting : String)
let greeting = "Hello, " ++ name
let name = "World"
let { greeting } = Task.run "greet.kl" (name)
greeting -- "Hello, World"
Sequential Tasks
You can run multiple tasks in sequence. Each task can use values from earlier tasks:
let x = 5
let { a } = Task.run "step1.kl" (x)
let { b } = Task.run "step2.kl" (x)
a + b
Chained Tasks
Task files can themselves run other tasks, creating chains of composition:
-- inner.kl
Task.define (n : Int) -> (doubled : Int)
let doubled = n * 2
-- outer.kl
Task.define (x : Int) -> (final_result : Int)
let n = x
let { doubled } = Task.run "inner.kl" (n)
let final_result = doubled + x
let x = 5
let { final_result } = Task.run "outer.kl" (x)
final_result -- 15
File paths are resolved relative to the calling file's directory, so nested files can use relative paths like "../sibling/file.kl".
Error Handling
The compiler catches task errors at compile time:
| Error | Cause |
|---|---|
| File not found | The file path doesn't resolve to an existing file |
| Parse errors in task file | The referenced file has syntax errors |
| Circular task dependency | File A runs B, which runs A (or a file runs itself) |
| Missing expected variable | The callee expects a parameter the caller didn't pass |
| Extra variable | The caller passes a variable the callee doesn't expect |
| Type mismatch | A passed variable's type doesn't match the expected type |
| Mut variable not in params | A mut variable in the output declaration isn't one of the task's input parameters |
All errors are reported at compile time — there are no runtime surprises.
Best Practices
- Keep task files focused — each file should do one thing
- Be explicit about interfaces — use typed
Task.definedeclarations to document inputs and outputs - Prefer named variables —
Task.run "f.kl" (x)is clearer thanTask.run "f.kl" (..) - Destructure only what you need —
let { result } = Task.run ...is clearer than binding everything - Avoid deep nesting — chained tasks are powerful, but deep chains become hard to follow
- Use descriptive file names —
compute_totals.klis clearer thanstep2.kl
Next Steps
Learn about error handling to understand Keel's helpful error messages, or explore the standard library for built-in functions.