SCUA

News

New ways to write Results and records

September 28, 2026

SCUA 0.30.0 adds r matches Ok and r matches Error, which answer true or false for whether a Result succeeded. It also makes as(value, Record) the way to turn data into a record, and a record now keeps its type wherever it goes: to an actor, into a save and back, or across a cluster. A few older spellings stop working, and the last section lists each one with what to write now.

#matches Ok tests a Result

A function that can fail returns Ok or Error. When you only need to know which one came back, ask with matches:

import json

fn parse_port(text)
  let n = tonumber(text)
  if n == nil or n < 1 or n > 65535 then return Error(`not a port: {text}`) end
  return Ok(n)
end

for text in ["8080", "http"] do
  let r = parse_port(text)
  if r matches Ok then
    print(`{text}: listening`)
  end
  if r matches Error then
    print(`{text}: using the default`)
  end
end

print(json.decode("[1, 2") matches Error)
print(nil matches Ok)
8080: listening
http: using the default
true
false

Both give true or false and work on any value, so nil matches Ok is just false. When you want what is inside the Result as well, match is still the way to take it apart.

A Result used directly as a condition stops with an error that shows both forms. Where the compiler can see that a function always returns Ok or Error, it says so before the program runs. With the same parse_port:

if parse_port("http") then
  print("listening")
end
scua: condition.scua:7: type error: a condition got a Result (`parse_port` always returns Ok or Error): an Ok/Error is never true or false. Handle both cases:
  match r  Ok(v) -> …  Error(e) -> …  end
Or test it: `r matches Ok` for success, `r matches Error` for failure.

A flags set works the same way. Ask whether it holds a member with in, as in Perm.Write in perms, and whether it holds any of several with perms & (Perm.Read | Perm.Write) != Perm.none. Everything else in a condition behaves as before: 0, "" and empty collections are true, and only nil and false are false.

#as turns data into a record

Decoded JSON, a loaded save and a table built by hand are all plain data. as(value, Record) makes a record from it. It copies the fields, fills in any defaults the data leaves out, runs the record's migrate hook for data in an older shape, and checks the result, at every level of nesting:

import json

record Player { name: string, level: int = 1, gold: int = 0 }

migrate Player(old)
  return { name = old.nick ?? old.name, level = old.level ?? 1, gold = old.coins ?? old.gold ?? 0 }
end

fn describe(p: Player) -> string
  return `{p.name}, level {p.level}, {p.gold} gold`
end

let saves = [
  "{\"name\": \"Ana\", \"level\": 7, \"gold\": 120}",
  "{\"name\": \"Bo\"}",
  "{\"nick\": \"Cy\", \"coins\": 40}",
]

for text in saves do
  match json.decode(text)
    Ok(data) -> print(as(data, Player).describe())
    Error(e) -> print(`not JSON: {e}`)
  end
end
Ana, level 7, 120 gold
Bo, level 1, 0 gold
Cy, level 1, 40 gold

The second save has no level or gold and gets the defaults. The third is from before nick became name and coins became gold, and the hook reads it. What comes back is a Player every time, so its methods work on it.

A type written on a let, a parameter or a return now only checks a value, and leaves it as it was. So when you see as(...), you know data is being turned into a record at that point. A literal written right where the type is declared still builds the record, as before: let p: Point = { x = 1, y = 2 } is a Point, and so is a literal returned from a function declared -> Point.

#Records keep their type wherever they go

A record you send to an actor arrives as that record, with its methods. The same holds when it is nested inside another value, returned from an ask, or kept in an actor's state while the actor is frozen with actors.freeze and brought back with actors.revive:

import actors

record Line { item: string, cents: int, qty: int = 1 }
record Order { id: string, lines: { Line } }

fn cost(l: Line) -> int return l.cents * l.qty end
fn cost(o: Order) -> int
  let sum = 0
  for l in o.lines do sum = sum + l.cost() end
  return sum
end

partition Till
  state last = nil
  on Take(o)
    last = o
    print(`order {o.id}: {o.cost()} cents`)
  end
  on Show() print(`after a revive, order {last.id} is still an Order: {last.cost()} cents`) end
end

let till = Till()
tell till.Take(as({ id = "A-17", lines = [{ item = "tea", cents = 250, qty = 2 }, { item = "cake", cents = 320 }] }, Order))
wait(100ms)

fn restart(a)
  let blob = actors.freeze(a)?
  actors.drop(a)
  return actors.revive(blob)
end

match restart(till)
  Ok(revived) -> tell revived.Show()
  Error(e) -> print(`revive refused: {e.message}`)
end
order A-17: 820 cents
after a revive, order A-17 is still an Order: 820 cents

There are two functions called cost here, one for a Line and one for an Order. The actor picks the right one because each value it holds still knows which record it is, including the lines nested inside the order.

A revive checks each record type the saved actor holds, by name. If you change the fields of Order and then revive an actor that holds one, the refusal names Order and lists the saved fields beside the current ones. Changing a record type the actor does not hold has no effect on the revive. Before you deploy a change to a record, scua blob-info shows which record types a save holds, with their fields.

A shared cluster table keeps records too. Here one node stores two records, and a second node on the same machine reads them back and calls the method for each:

import cluster
import sys

record Price { item: string, cents: int }
fn label(p: Price) -> string return `{p.item} at {p.cents} cents` end
record Stock { item: string, count: int }
fn label(s: Stock) -> string return `{s.count} {s.item}s in stock` end

let me = tonumber(sys.args()[0])
let peer = tonumber(sys.args()[1])
let cfg = cluster.open("prices")
cluster.join({ table = "prices", bind = "127.0.0.1", port = me, seeds = [`127.0.0.1:{peer}`] })

if me < peer then
  cluster.set(cfg, "price/apple", as({ item = "apple", cents = 30 }, Price))
  cluster.set(cfg, "stock/apple", as({ item = "apple", count = 12 }, Stock))
  wait(3s)
else
  for i in 0:50 do
    if cluster.has(cfg, "stock/apple") then break end
    wait(100ms)
  end
  print(cluster.get(cfg, "price/apple").label())
  print(cluster.get(cfg, "stock/apple").label())
end
$ scua --allow-cluster nodes.scua 17966 17967 &
$ scua --allow-cluster nodes.scua 17967 17966
apple at 30 cents
12 apples in stock

A node whose program declares the record with different fields refuses the value and names the record. During a rolling deploy that changes a stored record, write the new shape under a new key until every node runs the new program.

#What changes in your code

Most of these stop with an error that names the fix. The third and the last change what a program does without an error, so they are the ones to search your code for.

  • A Result or a flags set is no longer accepted as a condition, in if, while, assert, not, and/or, a match guard or a where rule. Write r matches Ok, take it apart with match, or test a set with Perm.Write in perms. For a default when a value is missing, use ??.
  • Ok and Error are reserved. A record, enum variant, contract or message with either name is an error. Rename it, for example to ParseError.
  • A typed let, parameter, return or field no longer fills a missing default or runs migrate, so a defaulted field the data leaves out reads nil. Where you loaded a save or decoded JSON through a typed let, add as: let hero = as(save, Player). A literal passed to a record-typed parameter is checked, not built, so write heal(as({ name = "Ed", hp = 10 }, Player)) when the function needs the defaults.
  • A record you receive in a message is sealed like one you built yourself, so adding a field it does not declare is an error. Copy the fields you want into a table of your own, or declare the field.
  • A record goes at the top level of a file. Declared inside a function or a block, it is now an error.
  • x matches C is always true or false. Code that used the value of the last clause should use that expression directly.

The changelog has every change in 0.30.0. Testing a Result and Records and types in the guide cover the new forms, and examples/results-and-flags.scua and examples/record-messages.scua in the distribution run them.