Esc
Start typing to search...

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:

FormMeaningExample
(x, y)Named variablesTask.run "f.kl" (x, y)
(..)All variablesTask.run "f.kl" (..)
()No variablesTask.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:

  1. The caller passes variables: Task.run "file.kl" (x, y) or Task.run "file.kl" (..)
  2. The callee declares typed parameters: Task.define (x : Int, y : Int) -> ...
  3. The callee declares typed outputs: -> (result : Int)
  4. 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:

ErrorCause
File not foundThe file path doesn't resolve to an existing file
Parse errors in task fileThe referenced file has syntax errors
Circular task dependencyFile A runs B, which runs A (or a file runs itself)
Missing expected variableThe callee expects a parameter the caller didn't pass
Extra variableThe caller passes a variable the callee doesn't expect
Type mismatchA passed variable's type doesn't match the expected type
Mut variable not in paramsA 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

  1. Keep task files focused — each file should do one thing
  2. Be explicit about interfaces — use typed Task.define declarations to document inputs and outputs
  3. Prefer named variablesTask.run "f.kl" (x) is clearer than Task.run "f.kl" (..)
  4. Destructure only what you needlet { result } = Task.run ... is clearer than binding everything
  5. Avoid deep nesting — chained tasks are powerful, but deep chains become hard to follow
  6. Use descriptive file namescompute_totals.kl is clearer than step2.kl

Next Steps

Learn about error handling to understand Keel's helpful error messages, or explore the standard library for built-in functions.