Skip to content

Expressions

Skein is expression-oriented — everything produces a value. The last expression in a block is its return value. There is no return keyword.

fn abs_value(n: Int) -> Int {
match n > 0 {
true -> n
false -> 0 - n
}
}
42
0
-7
1000

Compile to Erlang integers via :cerl.abstract/1.

true
false

Compile to Erlang atoms true and false.

Plain strings:

"hello world"

Strings with interpolation:

"Hello, ${name}!"
"id: ${user.id}"

Interpolation accepts an identifier with optional dot access (${name}, ${user.id}) — not arbitrary expressions. To interpolate a computed value, bind it first:

let result = a + b
"Result: ${result}"

String interpolation compiles to erlang:iolist_to_binary/1 over an iolist. For example, "Hello, ${name}!" compiles to:

call 'erlang':'iolist_to_binary'([<<"Hello, ">>, Name, <<"!">>])

This approach handles mixed literal and dynamic segments efficiently.

All binary operations compile to Core Erlang calls to the corresponding Erlang BIF:

SkeinCore Erlang
a + bcall 'erlang':'+'(A, B)
a - bcall 'erlang':'-'(A, B)
a * bcall 'erlang':'*'(A, B)
a / bruntime dispatch: 'erlang':'/' if either operand is a float, else 'erlang':'div'
a == bcall 'erlang':'=='(A, B)
a != bcall 'erlang':'/='(A, B)
a < bcall 'erlang':'<'(A, B)
a > bcall 'erlang':'>'(A, B)
a <= bcall 'erlang':'=<'(A, B)
a >= bcall 'erlang':'>='(A, B)
a && bcall 'erlang':'and'(A, B)
a || bcall 'erlang':'or'(A, B)

Note the Erlang-specific mappings: != becomes '/=' and <= becomes '=<'. Division compiles to a runtime dispatch — float operands use Erlang '/' (float division), integer operands use 'div' (integer division).

Let bindings compile to Core Erlang let expressions:

let doubled = x + x
doubled * 3

Becomes:

let <Doubled> = call 'erlang':'+'(X, X)
in call 'erlang':'*'(Doubled, 3)

Match compiles to Core Erlang case expressions:

match n > 0 {
true -> "positive"
false -> "non-positive"
}

Becomes:

case call 'erlang':'>'(N, 0) of
<'true'> when 'true' -> <<"positive">>
<'false'> when 'true' -> <<"non-positive">>
end

Match arms can have:

  • Boolean patterns: true, false
  • Integer patterns: 0, 42
  • String patterns: "active", "paused"
  • Variable patterns: x (binds the matched value)
  • Wildcard pattern: _ (matches anything, discards value)
  • Enum variant patterns: Active, Status.Active (matches atom variants)
  • Enum variant with fields: Event.Charge(amt) (destructures into bound variables)

Enum variants can be pattern matched with optional field destructuring:

enum Result {
Ok(value: Int)
Err(message: String)
}
fn unwrap_or(r: Result, default: Int) -> Int {
match r {
Result.Ok(v) -> v
Result.Err(msg) -> default
}
}

Simple variants without fields match as atoms:

enum Status {
Active
Inactive
}
fn is_active(s: Status) -> Bool {
match s {
Active -> true
_ -> false
}
}

Variant patterns compile to Core Erlang tuple patterns. For example, Result.Ok(v) becomes a pattern matching the tuple {:ok, V}.

A match arm may carry a guard: the arm is selected only when the pattern matches and the guard evaluates to true. A failing guard falls through to the later arms.

match order.total {
t if t > 1000 -> "review"
t if t > 0 -> "approve"
_ -> "reject"
}

Guards see the pattern’s bindings and must be Bool. They are restricted to a guard-safe subset — literals, bindings, field access, comparisons, boolean operators, and +/-/* arithmetic. Calls, effects, division, and string interpolation in a guard are compile errors (E0027); compute those values in a let before the match. A guarded arm does not count toward exhaustiveness, and a match where every arm’s guard fails raises case_clause at runtime.

See Types > Enum Variant Matching for details on exhaustiveness checking.

Local function calls compile to Core Erlang apply:

add(3, 4)

Becomes:

apply 'add'/2(3, 4)

Arguments can be passed by name, in any order, after any positional arguments:

fn describe(name: String, count: Int) -> String { "${name}" }
describe(count: 3, name: "widget") -- all named, reordered freely
describe("widget", count: 3) -- positional first, then named

Named arguments work for calls to functions in the same module or agent, and for effect calls with documented signatures (llm.chat(model:, system:, input:), memory.put(key:, value:), http.post(url:, json:), and so on).

The analyzer resolves names against the callee’s declared parameter names and rewrites the call into positional order at compile time, so there is no runtime cost — codegen only ever sees positional arguments. These are compile errors (E0026, with a fix_hint listing the valid names):

  • An unknown or duplicate argument name
  • A positional argument after a named one
  • Naming a parameter that was already filled positionally
  • Named arguments on a callee without a known signature (e.g. stdlib calls)

Patterns never use named arguments — Event.Charge(amount: 5) in a match arm is a parse error.

Skein uses snake_case for variable names. Core Erlang requires capitalized variable names. The compiler transforms variable names:

SkeinCore Erlang
xX
nameName
my_varMyVar
ticket_idTicketId

The transformation splits on underscores, capitalizes each segment, and joins them.

The code generator needs temporary variables for intermediate results (e.g., match subjects). These are generated using a process-local counter:

# Generated names: _skein_0, _skein_1, _skein_2, ...
defp gen_var do
counter = Process.get(:skein_var_counter, 0)
Process.put(:skein_var_counter, counter + 1)
String.to_atom("_skein_#{counter}")
end

These variables are invisible to the Skein programmer.