Skip to content

Effects System ​

Kettle tracks side effects in the type system. Functions must declare what effects they use, making code easier to reason about.

What Are Effects? ​

Effects represent operations that go beyond pure computation:

  • IO β€” I/O operations (printing, reading/writing files)
  • Fail[E] β€” Operations that can fail with error type E
  • Quantum β€” Quantum operations (creating qubits, gates, measurement)

A function declares its effects with the with clause:

kettle
fn read_number() -> Int with IO, Fail[String]
  -- can do IO and can fail
end fn

fn create_superposition() -> Qubit with Quantum
  -- can do quantum operations
end fn

The with Clause ​

Declare effects after the return type:

kettle
-- Pure function (no effects)
fn add(a: Int, b: Int) -> Int
  a + b
end fn

-- Function with Fail effect
fn divide(a: Int, b: Int) -> Int with Fail[String]
  match b == 0
    True -> fail("division by zero")
    False -> a / b
  end match
end fn

-- Function with multiple effects
fn read_and_parse() -> Int with IO, Fail[String]
  content = read_file("data.txt")
  -- ...
end fn

The Fail Effect ​

Fail[E] represents computations that can fail with an error of type E.

Using fail ​

Trigger a failure:

kettle
fn divide(a: Int, b: Int) -> Int with Fail[String]
  match b == 0
    True -> fail("division by zero")
    False -> a / b
  end match
end fn

Using try ​

Propagate failures from called functions:

kettle
fn compute(x: Int, y: Int) -> Int with Fail[String]
  a = try divide(x, y)      -- propagates failure if divide fails
  b = try divide(a, 2)
  a + b
end fn

Using catch ​

Convert a failing computation to a Result:

kettle
fn safe_divide(a: Int, b: Int) -> Result[Int, String]
  catch divide(a, b)
end fn

fn main() -> Unit using file_io
  match safe_divide(10, 0)
    Ok(n) -> print("Result: ${to_string(n)}")
    Err(msg) -> print("Error: ${msg}")
  end match
end fn

Using catch with Default ​

Provide a fallback value:

kettle
fn divide_or_zero(a: Int, b: Int) -> Int
  catch divide(a, b) else 0
end fn

Effect Propagation ​

Effects propagate up the call stack. If you call a function with effects, you must either:

  1. Declare the same effect β€” let it propagate
  2. Handle the effect β€” use catch, using, etc.
kettle
-- Option 1: Propagate
fn caller() -> Int with Fail[String]
  try divide(10, 2)
end fn

-- Option 2: Handle
fn caller() -> Result[Int, String]
  catch divide(10, 2)
end fn

The Quantum Effect ​

Quantum operations require the Quantum effect:

kettle
fn bell_pair() -> Tuple[Int, Int] with Quantum
  (a, b) = bell()
  (measure(a), measure(b))
end fn

Handling Quantum Effects ​

Use using to provide a quantum backend:

kettle
fn main() -> Unit using quantum_simulator(shots = 100), file_io
  results = bell_pair()  -- runs 100 times
  print(to_string(results))
end fn

IO Effect ​

File operations require the IO effect:

kettle
fn load_config(path: String) -> String with IO, Fail[String]
  read_file(path)
end fn

fn save_data(path: String, data: String) -> Unit with IO, Fail[String]
  write_file(path, data)
end fn

The IO effect covers:

  • print(value) β€” Print to standard output
  • read_file(path) β€” Read file contents
  • write_file(path, content) β€” Write to a file
  • file_exists(path) β€” Check if a file exists

Effect Handlers: with vs using ​

There's an important distinction between declaring effects and handling them:

  • with β€” Declares that a function requires certain effects
  • using β€” Provides handlers that fulfill those effects

How It Works ​

When you write fn foo() -> T with IO, you're saying "this function needs IO capabilities." The function can call print, read_file, etc., but it doesn't specify how those operations work.

When you write fn main() -> Unit using file_io, you're providing a handler that actually implements IO operations. The handler connects your code to the real world (file system, console, etc.).

kettle
-- This function REQUIRES IO (declares the need)
fn greet(name: String) -> Unit with IO
  print("Hello, ${name}!")
end fn

-- This function PROVIDES IO via file_io handler
fn main() -> Unit using file_io
  greet("World")  -- works because file_io handles IO
end fn

Built-in Handlers ​

Kettle provides these handlers:

HandlerHandlesDescription
file_ioIOConsole and file operations
quantum_simulatorQuantumSimulates quantum circuits locally
quantum_simulator(shots=N)QuantumRuns circuit N times, returns list of results

Multiple Handlers ​

Provide multiple handlers separated by commas:

kettle
fn main() -> Unit using quantum_simulator(shots = 100), file_io
  -- Can use both Quantum and IO effects
end fn

Why This Design? ​

Separating declaration (with) from handling (using) enables:

  • Testing β€” Swap handlers to mock IO or quantum operations
  • Portability β€” Same code runs on simulator or real quantum hardware
  • Clarity β€” Function signatures show exactly what effects are needed

Combining Effects ​

Functions can have multiple effects:

kettle
fn read_and_parse() -> Int with IO, Fail[String]
  content = read_file("number.txt")
  match to_int(content)
    Some(n) -> n
    None -> fail("invalid number in file")
  end match
end fn

Effect Polymorphism ​

Higher-order functions like map, filter, and fold are effect-polymorphicβ€”they propagate effects from their callbacks to their callers.

How It Works ​

When you pass an effectful callback to map, the effects propagate:

kettle
-- Pure callback: map is pure
pure_result = map(fn(x: Int) -> x * 2, [1, 2, 3])

-- IO callback: map becomes IO
fn print_each(nums: List[Int]) -> List[Int] with IO
  map(fn(x: Int) -> print(to_string(x)); x, nums)
end fn

-- Quantum callback: map becomes Quantum
fn apply_hadamard(qubits: List[Qubit]) -> List[Qubit] with Quantum
  map(fn(q: Qubit) -> hadamard(q), qubits)
end fn

Defining Effect-Polymorphic Functions ​

You can define your own effect-polymorphic functions using effect parameters:

kettle
-- Syntax: effect E in type parameter list
fn twice[T, effect E](f: Fn(T) -> T with E, x: T) -> T with E
  f(f(x))
end fn

-- Works with pure functions
twice(fn(x: Int) -> x + 1, 5)  -- 7

-- Works with IO functions
fn greet(name: String) -> String with IO
  print("Hello, ${name}!")
  name
end fn

fn main() -> Unit using file_io
  twice(greet, "World")  -- prints twice
end fn

The effect parameter E captures whatever effects the callback has and propagates them to the caller.

Practical Use Case: Gradient Computation ​

Effect polymorphism is particularly useful for gradient computation in quantum algorithms:

kettle
-- Works with both pure and quantum cost functions
fn gradient[effect E](f: Fn(Float) -> Float with E, theta: Float) -> Float with E
  shift = 1.5708  -- pi/2
  (f(theta + shift) - f(theta - shift)) / 2.0
end fn

-- Pure function
fn parabola(x: Float) -> Float
  x * x
end fn

-- Quantum function
fn quantum_cost(theta: Float) -> Float with Quantum
  q = qubit()
  q <- ry(theta)
  (e, _) = expect_z(q)
  e.value
end fn

-- Same gradient function works for both
grad1 = gradient(parabola, 1.0)

fn quantum_gradient_example() -> Float with Quantum
  gradient(quantum_cost, 0.5)
end fn

Why Effects Matter ​

Effects make code honest about what it does:

  • Testability β€” Pure functions are easy to test
  • Refactoring β€” Effect signatures show dependencies
  • Reasoning β€” You know what a function can and can't do

A function without effects is guaranteed to be pureβ€”same inputs always produce same outputs, no side effects.

Next Steps ​