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:

{stdout, write} = import 'std/io.fnk'

main = fn ..args:
  write stdout, 'Hello, ƒink!'
  0

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

# end-of-line comment

---
block comment
---

Literals

Booleans

true
false

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 shape

Floats and decimals

Floats are sized the same way (f32 / f64). Decimals are a distinct type and don't mix with floats.

1.0                    # f32
1.0e100_000            # f64
1.0d                   # decimal

Strings

Single-quoted. A string with ${expr} inside is a template string — the expression is evaluated and interpolated. Escape sequences work in any string.

'hello world'

'result: ${1 + 2}'

'line one\nline two'

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:

foo = '
  one
  two
  three
'

# foo == '\none\ntwo\nthree\n'

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:

foo = ":
  one
  two

# foo == 'one\ntwo'
":
  template interpolation ${name}
  and 'quotes' without 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 processing

A 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.

[]
[1, 2, 3]

Multiline:

numbers = [
  1
  2
  3
]

For a specific sequential type, call its constructor with the elements as arguments:

{set} = import 'std/set.fnk'

set 1, 2, 2, 3   # set of 1, 2, 3 — duplicates collapsed
{list} = import 'std/list.fnk'

list 1, 2, 3     # explicit list constructor

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.

{}
{foo: 1, bar: 2}
{'foo bar': 42}
{(1 + 1): 'two'}

point = {x: 1, y: 2}

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).

foo
foo-bar
foo_bar
ni_1234

_ 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 element

Operators

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     # chained

Logical

Operate on booleans and return a boolean.

not a
a and b
a or b
a xor b

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 right

Ranges

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 clarity

Range 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 form

Member access

By name:

point.x
foo.bar.spam

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 identifiers

Spread

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:

15 == (1 + 2) * (2 + 3)

[3, 7] == [
  add 1, 2
  add 3, 4
]

[3, 7] == [add 1, 2; add 3, 4]

Bindings

ƒink bindings use pattern matching — the left side is a pattern, the right side is the value.

Left-hand

foo = 1

[a, b] = [1, 2]
{x, y} = point
{x, y: z} = point        # bind x to point.x and y to point.z

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:

foo
  arg1
  arg2
|= result

Guards

Any pattern position accepts a guard — a boolean expression that must hold for the pattern to match.

[x, y > 2] = [1, 3]
[x, is_even y] = [1, 4]      # assumes a user-defined `is_even`

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=6

Keyed 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 rest

String patterns

A template string on the left-hand side captures interpolation holes from a literal-on-the-right.

'start ${middle} end' = 'start foo end'
# middle == 'foo'

Functions

Defined with fn args: body. Zero args is fn: body.

add = fn a, b:
  result = a + b
  result

A single-line form is also fine when the body is short:

add = fn a, b: a + b

greet = fn: 'hello'

Pattern-matched parameters

Same pattern language as bindings:

sum = fn {x, y}: x + y
head = fn [head, ..]: head

Varargs

One trailing ..rest parameter captures the rest of the arguments as a sequence. (Interpolating a sequence renders it as [a, b, c].)

log = fn prefix, ..parts:
  '${prefix}: ${parts}'

log 'tags', 'red', 'green'   # 'tags: [red, green]'

fn match

Syntactic sugar for fn args: match args:. Use when the whole function body is a match on the parameter.

classify = fn match n:
  n > 0: 'positive'
  n < 0: 'negative'
  _:     'zero'

is the same as

classify = fn n: match n:
  n > 0: 'positive'
  n < 0: 'negative'
  _:     'zero'

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):

is_even = fn n:
  match n:
    0: true
    _: is_odd  n - 1

is_odd = fn n:
  match n:
    0: false
    _: is_even n - 1

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, 4

Nested application is right-to-left:

foo bar spam
# same as:
foo (bar spam)

To call a zero-argument function, pass the wildcard _ as the sole argument:

greet = fn: 'hello'

greet _                  # calls greet with no arguments

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:

10sec                    # sec 10
10.5min                  # min 10.5
(foo)min                 # min foo

Partial application with ?

? in an expression stands for a hole that, taken together with the expression's scope, becomes a function of one argument.

add5 = add 5, ?
# same as:
add5 = fn $: add 5, $

inc = ? + 1
# same as:
inc = fn $: $ + 1

? 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 at rhs, 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 twice

Parenthesise to narrow the scope:

{bar: (? + 2), spam: (? + 3)}
# same as:
{bar: fn $: $ + 2, spam: fn $: $ + 3}

Pipes

| applies left-to-right. Each pipe segment is its own partial-application scope.

'hello' | capitalize | log
# same as:
log capitalize 'hello'

With partial application, each segment uses ? for the incoming value:

add = fn a, b: a + b

result = 2
  | add 3, ?           # add 3 to 2
  | add 10, ?          # then add 10
# result == 15

Use ..? to splat a sequence into multiple arguments:

[1, 2] | add ..?         # add 1, 2

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:

match 'hello world':
  'hello ${rest}': rest      # 'world'
  _: ''

Modules

A file is a module. Names bound at the top level are its exports.

# ./greet.fnk
{stdout, write} = import 'std/io.fnk'

hello = fn name:
  write stdout, 'hello ${name}'

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.fnk and ../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.

{stdout, write} = import 'std/io.fnk'

main = fn ..args:
  write stdout, 'Hello, world'
  0

Writing to a stream

write stream, value sends value to stream and returns stream. The return value enables chaining via the pipeline operator:

stdout
| write ?, 'foo'
| write ?, 'bar'

This writes foobar to stdout. write is the recommended way to send to streams.

Low-level operators. >> and << are channel-send operators (x >> stream and stream << x) that also serve as bitwise shifts; dispatch is by value type. They remain available but write is preferred for stream IO.

Receiving from a channel

receive parks the current task until a message arrives:

line = receive stdin

Spawning and awaiting

spawn creates a task from a zero-arg function; await blocks on its result.

future = spawn fn:
  compute_something

result = await future

Reading raw bytes

read stream, n reads up to n bytes from a host stream (stdin, typically):

bytes = read stdin, 1024

Block scoping

Every indented body is its own scope; bindings inside don't leak out.

result = (
  tmp = 10 + 20
  tmp * 2
)
# tmp is not in scope here
# result == 60

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.

add
  mul 2, 3
  mul 3, 4

# continuation after a comma is fine
foo bar,
  spam

For inline statement separation with ; see Precedence and grouping.


Further reading