Language Reference
ƒink is a small, functional, indentation-based language. Values are immutable, types are inferred, IO goes through channels.
Features not yet reachable in the compiler live in the Roadmap. For the execution model — what effects are, how modules run, how mutual recursion and IO fit — see the Execution Model.
Quickstart
Save as hello.fnk:
Run it:
fink hello.fnk
fink <file> is shorthand for fink run <file> — run is the default subcommand.
You'll see Hello, ƒink! on stdout. main returns an exit code — 0 for success. For more on write, channels, spawn / await and the rest, see Concurrency and IO.
Comments
Literals
Booleans
Integers
Integer types are inferred from the literal's shape and value (the values below show the inferred type — type information isn't surfaced in tooling yet). Underscores separate digit groups and are ignored.
Decimal literals belong to the math family (signed, mix freely with floats). Hex / octal / binary literals belong to the bits family (unsigned, used for masks and bit patterns; don't mix with the math family). Width is the smallest type that fits the literal's value (signed range for signed literals, unsigned range for unsigned). To convert across families, use the std-lib helpers int(x) / uint(x) from std/math.
1_234_567 # i32 — bare decimals are signed
+1 # i8 — sign prefix forces signed
-1 # i8
0xFF # u8 — hex/oct/bin are unsigned
+0xFF # i8 — sign prefix forces signed (overrides bits family)
0xFfFf # u16
0xFFFF_FFFF # u32
0xFFFF_FFFF_FFFF_FFFF # u64
0o_1234_5670 # octal — unsigned by shape
0b_0101_1111 # binary — unsigned by shapeFloats and decimals
Floats are sized the same way (f32 / f64). Decimals are a distinct type and don't mix with floats.
Strings
Single-quoted. A string with ${expr} inside is a template string — the expression is evaluated and interpolated. Escape sequences work in any string.
Multiline strings start with ' alone on a line and indent the content. Common leading whitespace is stripped from each line, and the surrounding newlines are preserved:
Block strings begin with ": and end when the indent drops back. Common leading whitespace is stripped, but unlike the multiline ' form, leading and trailing newlines are not included in the value. Template interpolation and embedded single-quotes need no escaping:
Use ' when you want the surrounding newlines preserved; use ": when you want a clean trimmed string and don't want to escape interior ' or ${...} segments.
Escape sequences:
'
\n - new line
\r - carriage return
\v - vertical tab
\t - tab
\b - backspace
\f - formfeed
\\ - backslash
\' - single quote
\$ - dollar sign
\x0f - hex byte (exactly 2 hex digits)
\u{ff} - Unicode code point between U+0000 and U+10FFFF
\u{10_ff_ff} - underscores allowed for readability
'Tagged templates
A function name immediately before a string literal calls the function with the string's parts and interpolated values. The standard library exposes two tags from std/str.fnk:
{fmt, raw} = import 'std/str.fnk'
fmt'result: ${1 + 2}' # interpolates — same as 'result: ${1 + 2}'
raw'line\nbreak' # leaves \n literal — no escape processingA tag is just a function. It receives (parts, vals) — parts is the sequence of literal segments, vals is the sequence of interpolated values. Defining your own:
fmt_log = fn parts, vals:
...
fmt_log'hello ${name}, you have ${count} messages'
# calls fmt_log with parts ['hello ', ', you have ', ' messages'], vals [name, count]Collections
Collections come in two literal shapes:
- Sequential —
[ ... ]— an ordered series of values. - Keyed —
{ ... }— values addressed by a key.
The shape is syntax. The runtime type is chosen by the compiler from what the literal contains. Sequential literals default to a list. Keyed literals default to a record when every key is known at compile time, otherwise a dict. To pick a different type explicitly — for example to dedup with a set — call its constructor.
Sequential — [ ... ]
Ordered, zero-indexed.
Multiline:
For a specific sequential type, call its constructor with the elements as arguments:
Keyed — { ... }
A record has keys known at compile time. They can be identifiers, string literals (for keys with spaces or unusual characters), or parenthesised expressions the compiler can resolve at compile time.
When a key resolves only at runtime, the compiler builds a dict instead. There's no user-facing dict constructor yet — see the Roadmap.
Identifiers and wildcards
Identifiers are sequences of UTF-8 graphemes. Hyphens and underscores are fine inside a name (whitespace around operators disambiguates from subtraction).
_ is the wildcard — a non-binding placeholder, not a name. Use it in patterns and parameter positions to discard.
_ # in a pattern, discard
fn _, b: b # ignore the first argument
[_, x] = [1, 2] # discard first elementOperators
Arithmetic
-a # unary minus
a + b
a - b
a * b
a / b
a // b # integer divide
a ** b # power
a % b # remainder (sign follows dividend)
a %% b # true modulus (sign follows divisor)
a /% b # divmod — returns [quotient, remainder]Comparison
Comparison operators produce a bool and chain naturally:
a > b
a >= b
a < b
a <= b
a == b
a != b
a >< b # disjoint — a and b have no element in common
1 < x < 10 # chainedLogical
Operate on booleans and return a boolean.
Bitwise
Shared symbols with logical; dispatch is by value type.
not 0b0101_0101 # 0b1010_1010
0b1100 and 0b1010 # 0b0000_1000
0b1100 or 0b1010 # 0b0000_1110
0b1100 xor 0b1010 # 0b0000_0110
a << b # shift left
a >> b # shift right
a <<< b # rotate left
a >>> b # rotate rightRanges
0..10 # 0 inclusive, 10 exclusive
0...10 # 0 inclusive, 10 inclusive
-3.. # open ended from -3 onwards
1 + 2..3 + 4 # (1 + 2)..(3 + 4) — `..` binds looser than arithmetic
(1 + 2)..(3 + 4) # parens optional for clarityRange literals are first-class values.
Membership
in / not in test membership across any container that supports it — ranges, sequences, sets, and keyed types (records, dicts):
5 in 0..10 # range
2 in [1, 2, 3] # sequence element
'x' in {x: 1, y: 2} # keyed type — checks the keys, not the values
5 not in 0..3 # negated formMember access
By name:
By expression — the expression must be resolvable at compile time, or the operand's type must implement .:
[10, 20, 30].(0) # 10
key = 'x'
point.(key) # point.x
point.'x' # string-literal form of .(expr) — useful for keys that aren't valid identifiersSpread
Destructures on the left, splices on the right.
[head, ..tail] = [1, 2, 3]
greet = fn name, ..titles: '${name} — ${titles}'
both = [..left, ..right]
merged = {..a, ..b}Precedence and grouping
Parentheses group. Newlines separate statements.
; is the inline statement separator — it binds tighter than ,, so [add 1, 2; add 3, 4] is [(add 1, 2), (add 3, 4)], not [add 1, (2; add 3), 4]. Use it when you want to keep multiple statements on one line that would otherwise span several:
Bindings
ƒink bindings use pattern matching — the left side is a pattern, the right side is the value.
Left-hand
Right-hand
expr |= pat evaluates expr and binds it to pat. The same patterns as = work; the only difference is direction. Useful when the value-producing expression is long and the binding name reads better on the right:
Guards
Any pattern position accepts a guard — a boolean expression that must hold for the pattern to match.
Nesting and spread
Sequential patterns support spread anywhere — at the head, the tail, or in the middle:
[a, [b, c]] = [1, [2, 3]]
{a, b: {c, d}} = {a: 1, b: {c: 2, d: 3}}
[head, ..tail] = [1, 2, 3, 4] # head=1, tail=[2, 3, 4]
[..init, last] = [1, 2, 3, 4] # init=[1, 2, 3], last=4
[head, ..middle, end] = [1, 2, 3, 4] # head=1, middle=[2, 3], end=4
[a, b, ..mid, x, y] = [1, 2, 3, 4, 5, 6] # a=1, b=2, mid=[3, 4], x=5, y=6Keyed patterns match partially; sequential patterns match exactly
{a} = {a: 1, b: 2} # fine — keyed patterns match partially
[a] = [1, 2] # fails — sequential pattern has extra elements
[a, ..] = [1, 2] # fine — .. discards the restString patterns
A template string on the left-hand side captures interpolation holes from a literal-on-the-right.
Functions
Defined with fn args: body. Zero args is fn: body.
A single-line form is also fine when the body is short:
Pattern-matched parameters
Same pattern language as bindings:
Varargs
One trailing ..rest parameter captures the rest of the arguments as a sequence. (Interpolating a sequence renders it as [a, b, c].)
fn match
Syntactic sugar for fn args: match args:. Use when the whole function body is a match on the parameter.
is the same as
Higher-order, closures, recursion
Functions are values. They close over their enclosing scope. Module-level functions can refer to each other in any order (mutual recursion):
Mutual recursion only works at module scope. Inside a function body or block, bindings are not pre-declared — referring to a name before it is bound is an error. If you need mutual recursion in a nested scope, hoist the helpers to module level.
Application
Apply arguments to a function by writing them after it, separated by commas. (For ; see Precedence and grouping.)
log 'hello'
add 1, 2
add
mul 2, 3
mul 3, 4
# same as:
add (mul 2, 3), (mul 3, 4)
add mul 2, 3; mul 3, 4Nested application is right-to-left:
To call a zero-argument function, pass the wildcard _ as the sole argument:
Tagged postfix application
A literal followed by a function name applies the function to the literal. Useful for unit-like wrappers and other post-fix conversions:
Partial application with ?
? in an expression stands for a hole that, taken together with the expression's scope, becomes a function of one argument.
? bubbles up to the nearest scope boundary. The boundaries are:
- a parenthesised group
(...), - a pipe segment (everything between two
|s, or from a|to the start of the statement), - the right-hand side of a binding (
lhs = rhs— the bubble stops atrhs, never engulfs the=), - a standalone top-level expression.
All ? in the same scope refer to the same single parameter.
[?, ?] # fn $: [$, $]
{foo: ?, bar: ?} # fn $: {foo: $, bar: $}
(foo ?.(1), ?.(2)) # fn $: foo $.(1), $.(2) — one input, used twiceParenthesise to narrow the scope:
Pipes
| applies left-to-right. Each pipe segment is its own partial-application scope.
With partial application, each segment uses ? for the incoming value:
Use ..? to splat a sequence into multiple arguments:
Pattern matching
match tries each arm top-to-bottom; the first that matches wins. Bindings from the matching pattern are in scope for the arm's body.
classify = fn match n:
0: 'zero'
n > 0 and n < 10: 'small positive'
n > 0: 'large positive'
_: 'negative'Deep structural matching:
describe = fn match point:
{x: 0, y: 0}: 'origin'
{x: 0, y}: 'on y-axis at ${y}'
{x, y: 0}: 'on x-axis at ${x}'
{x, y}: '(${x}, ${y})'Sequential and keyed patterns support spread:
match items:
[]: 'empty'
[x]: 'one: ${x}'
[x, ..rest]: 'head ${x}, rest ${rest}'
[..init, last]: 'last ${last}, init ${init}'
match config:
{}: 'empty'
{debug: true, ..}: 'debug mode'
{..anything}: 'some config'Patterns match by shape, not concrete type. A [..] pattern matches anything sequence-like — list, set, range. A {..} pattern matches anything keyed — record, dict.
String patterns capture holes in a template:
Modules
A file is a module. Names bound at the top level are its exports.
hello is exported. The stdout and write names are bindings local to this module — they were brought in by import and are not re-exported.
Another module imports it by destructuring the result of import:
# ./example.fnk
{hello} = import './greet.fnk'
main = fn first_name, last_name:
hello '${first_name} ${last_name}'Path resolution:
./example.fnkand../bar.fnk— relative to the importing file.std/foo.fnk— bundled standard library (e.g.std/io.fnk,std/list.fnk,std/set.fnk,std/str.fnk).
Concurrency and IO
ƒink programs are cooperative — tasks yield at I/O and scheduler points. Values flow between tasks through channels. stdio behaves like channels.
main and the IO channels
The runner calls main with ..args — CLI argv. main returns an exit code. The IO channels (stdin, stdout, stderr) and the IO functions (read, write) come from import 'std/io.fnk', not as positional parameters.
Writing to a stream
write stream, value sends value to stream and returns stream. The return value enables chaining via the pipeline operator:
This writes foobar to stdout. write is the recommended way to send to streams.
Low-level operators.
>>and<<are channel-send operators (x >> streamandstream << x) that also serve as bitwise shifts; dispatch is by value type. They remain available butwriteis preferred for stream IO.
Receiving from a channel
receive parks the current task until a message arrives:
Spawning and awaiting
spawn creates a task from a zero-arg function; await blocks on its result.
Reading raw bytes
read stream, n reads up to n bytes from a host stream (stdin, typically):
Block scoping
Every indented body is its own scope; bindings inside don't leak out.
Record field bodies, match arm bodies, and function bodies behave the same way. Module scope is the only place where bindings are mutually recursive — order of definition does not matter inside a module.
Indentation
Indented lines continue the preceding construct. A decrease in indent ends the construct.
For inline statement separation with ; see Precedence and grouping.
Further reading
- Execution Model — how a ƒink program runs.
- Debugging — running ƒink under a debugger.
- Roadmap — designed features not yet reachable.