Xo is experimental. The source is now public on GitHub, or try Xo in your browser.
Menu · Effects and capabilities

Docs

Effects and capabilities

On this page

In most languages any function can open a file, read the clock, or call the network, and you find out by reading its body. In Xo the signature says it. Two ideas work together:

  • an effect is a name in the uses clause (fs.read, clock, stdio);
  • a capability is a value (Fs, Clock, Stdio) that grants the permission. main receives them all in os: Os; everything else must be passed one.

Pure, and what it costs to stop being pure

caps-basics/main.xo

/// Pure: no `uses` clause, so it cannot touch the outside world.
pub fn parse_pair(line: Str) -> (Str, Str)? {
  let (k, v) = line.cut("=")?
  (k.trim(), v.trim())
}

/// Reads one file: the signature says so, and the caller must hand over an `Fs`.
pub fn count_keys(fs: Fs, path: Str) -> Int ! FsErr uses fs.read {
  let text = fs.read_text(path)?
  var n = 0
  for line in text.lines() {
    if parse_pair(line) is Some(_) {
      n += 1
    }
  }
  n
}

/// Reads the clock and prints: both effects are in the signature.
pub fn stamp(clock: Clock, out: Stdio, msg: Str) uses (clock, stdio) {
  out.println("${clock.now().unix()}: ${msg}")
}

fn main(os: Os) {
  os.stdio.println("pure: ${parse_pair("port = 8080"):?}")
  match count_keys(os.fs, "no-such-file.conf") {
    Ok(n) => os.stdio.println("keys: ${n}")
    Err(e) => os.stdio.println("could not read: ${e.display()}")
  }
}

test "count_keys reads the fake file system" {
  os.fs.write("app.conf", b"a = 1\nb = 2\nnot a pair\n")?
  expect(count_keys(os.fs, "app.conf")?) == 2
}

test "stamp reads the virtual clock" {
  stamp(os.clock, os.stdio, "hello")
  expect(os.stdio.output()) == "1767225600: hello\n"
}

Output:

pure: Some(("port", "8080"))
could not read: no-such-file.conf: no such file or directory
  • parse_pair has no uses clause, so it cannot reach the outside world. Calling something that does is a compile error.
  • count_keys takes an Fs and declares uses fs.read. The effect is the promise, the Fs argument is the means.
  • stamp declares two effects, clock and stdio, in one uses (...) list.
  • The tests run against a fake os: an in-memory file system, a virtual clock fixed at 1767225600, and captured output. No test touches the disk.

Forgetting the declaration is caught at compile time, with the effect named:

pub fn greet(out: Stdio, name: Str) {
  out.println("hello, ${name}")
}

fn main(os: Os) { greet(os.stdio, "Ada") }

xo check says:

main.xo:1:8: error XO0701: `greet` is pub with no `uses` clause, so it is pure, but performs `stdio`
  note: declare them: `uses stdio` (`xo fix` writes it)
  note: main.xo:2:3: `Stdio.println` uses `stdio` here
  fix: add the clause `uses stdio`
1 module(s), 1 error(s), 0 warning(s)

Handing out less

A capability can be narrowed before it is passed on. A function that only needs to read gets a read-only view; one that writes under a directory gets a sub-directory.

caps-narrow/main.xo

/// A report writer should only be able to write under one directory.
pub fn save_report(out: Fs, name: Str, body: Str) -> () ! FsErr uses fs.write {
  out.write("${name}.txt", body.bytes())?
}

/// A previewer should only read.
pub fn preview(src: Fs, name: Str) -> Str ! FsErr uses fs.read {
  src.read_text("${name}.txt")?
}

fn main(os: Os) {
  let reports = os.fs.sub("/tmp").read_only()
  match save_report(reports, "xo-book-demo", "hello") {
    Ok(_) => os.stdio.println("saved")
    Err(e) => os.stdio.println("refused: ${e.display()}")
  }
}

test "a read only capability refuses a write" {
  let reports = os.fs.sub("/reports").read_only()
  match save_report(reports, "q1", "numbers") {
    Ok(_) => fault("the write should have been denied")
    Err(e) => expect(e.display()) == "q1.txt: permission denied"
  }
}

test "the same function writes through a writable one" {
  os.fs.create_dir_all("/reports")?
  let reports = os.fs.sub("/reports")
  save_report(reports, "q1", "numbers")?
  expect(preview(reports.read_only(), "q1")?) == "numbers"
}

test "paths cannot leave the sub capability" {
  let reports = os.fs.sub("/reports")
  match reports.read_text("../etc/passwd") {
    Ok(_) => fault("escaped")
    Err(Denied(_)) => ()
    Err(e) => fault("unexpected: ${e.display()}")
  }
}

Output:

refused: xo-book-demo.txt: permission denied
  • os.fs.sub("/tmp") is an Fs rooted at /tmp; .read_only() removes the write effects from what it will grant.
  • save_report cannot tell that its Fs is narrowed. It still returns an error value, permission denied, instead of failing in some other way.
  • A path that climbs out with .. is refused too (the third test).

Because the narrowing is a value, you can audit a program by reading main: whatever it never hands to a function, that function cannot use.

Effects that pass through

A higher order function should not guess what its argument does. An effect variable stands for “whatever f does”:

caps-fx/main.xo

/// Runs `f` twice. The effect variable `fx` is whatever `f` does, so `twice` does the same.
pub fn twice[fx: Effects](f: fn() -> () uses fx) -> () uses fx {
  f()
  f()
}

fn shout(out: Stdio, word: Str) uses stdio {
  out.println("${word.to_upper()}!")
}

fn main(os: Os) {
  // `twice` of a pure closure is pure; `twice` of a printing closure prints
  twice(fn() { let _ = "x".to_upper() })
  twice(fn() { shout(os.stdio, "hi") })
}

test "twice passes the effects through" {
  twice(fn() { shout(os.stdio, "go") })
  expect(os.stdio.output()) == "GO!\nGO!\n"
}

Output:

HI!
HI!

twice[fx: Effects] is pure when given a pure closure and prints when given a printing one, so one definition serves both.

What to take away

  • Read a signature’s uses clause to know what a function can reach.
  • Pass the narrowest capability that does the job.
  • Test with the fake os: the fakes are the same types as the real ones.

Next: Error handling.

↑ index