Learn Bern

Install it, open the REPL, and follow along - from your first expression to your own programs.

Installing Bern

Welcome to Bern! Getting it onto your machine takes a single line - pick the shell you use, paste it in, and you are ready to play.

Windows (PowerShell)

iex (irm https://bern-lang.github.io/Bern/install/install_bern.ps1)

Linux & macOS (Bash)

curl -fsSL https://bern-lang.github.io/Bern/install/install_bern.sh | bash

To check it worked, open a terminal and type bern with no arguments. That drops you straight into the interactive prompt - which is exactly where we are headed next, and the friendliest way to learn the language.

No strings attached: Bern is a single self-contained program. If you ever want it gone, just delete it - there are no system changes to undo.

The REPL

The REPL is the best place to start - and the best place to keep coming back to. Run bern with no arguments and you get an interactive prompt that reads a line, evaluates it, and prints the result, instantly. There is nothing to set up and nothing to break: type something, see what happens, then try the next thing. Parse or runtime errors are simply printed, and the session keeps going.

Here is the whole idea in four lines - go ahead and type them in:

[bern]: 1 + 2
3
[bern]: "Hello, " + "Bern!"
"Hello, Bern!"

Every example in this guide is a line you can paste straight into that prompt. As you read on, do exactly that - playing with each idea is the fastest way to make it stick.

The _ Variable

The result of the last expression is bound to _, so you can build on it:

[bern]: 1 + 2
3
[bern]: _ * 10
30

Multi-line Blocks

Block constructs (def ... do ... end, loop ... do ... end, and so on) are read across lines until they are complete, shown with a continuation prompt:

[bern]: def addy(x, y) do
    ...:     return x + y
    ...: end
[bern]: addy(3, 4)
7

REPL Commands

CommandAction
:help, :hShow the list of commands
:load <file>, :lLoad and run a .brn file into the current session
:resetClear local definitions
:quit, :qExit (or press Ctrl-D)

Command history is available with the up/down arrows and is saved between sessions.

Basics

This is where the fun starts. Everything below is something you can type into the REPL right now and watch happen - so keep that prompt open and follow along. We will build up from values and variables to functions and your own little programs, one small idea at a time.

Literals

In Bern, literals are automatically interpreted and printed. You can write values directly and they'll be evaluated immediately:

"Hello, World!"
23
3.14
true

Variables

Variables are defined using the simple name = value syntax. Bern supports various data types including integers, doubles, strings, and booleans:

integer = 2
double = 3.14
helloworld = "Hello, World!"
isActive = true

Arithmetic

Numbers do exactly what you would expect. There are five arithmetic operators, and you can try every one of them in the REPL right now:

7 + 2      -- 9    (add)
7 - 2      -- 5    (subtract)
7 * 2      -- 14   (multiply)
7 / 2      -- 3    (divide)
7 % 2      -- 1    (remainder, or "modulo")

One thing to know about division: between two whole numbers it divides and drops the remainder, but a decimal anywhere keeps the fraction.

7 / 2      -- 3     (integer division)
7.0 / 2    -- 3.5   (a double makes it exact)

* and / bind tighter than + and -, just like in maths, and parentheses override that:

2 + 3 * 4    -- 14   (the 3 * 4 happens first)
(2 + 3) * 4  -- 20

Every operator also has a plain-English name (7 plus 2, 7 times 2) - see Operators for the full set, including the pipe.

Evaluation

Like literals, variables can be called directly and will be properly evaluated. Simply write the variable name to see its value:

helloworld
-- Output: "Hello, World!"

integer
-- Output: 2

Type Checking and Length

Bern provides two special operators for introspection:

  • :: (typeof) - Returns the type of a value as a string
  • :> (length) - Returns the length of a list, set, or string
typeof_integer = :: 2
-- Output: "Integer"

typeof_string = :: "hello"
-- Output: "List"   (a string is a list of characters)

length_list = :> [1,2,3,4]
-- Output: 4

length_string = :> "hello"
-- Output: 5

See the Types section for the full list of values :: can report.

Types

Bern is dynamically typed: a variable can hold a value of any type, and types are checked as the program runs rather than declared up front. You never have to annotate a variable's type. Even so, it helps to know exactly which types exist, because the :: (typeof) operator reports them by name and pattern matching reasons about them - and because ADTs are built on top of them.

Built-in Types

Every value in Bern is one of the following. The typeof name column is the exact string that :: returns for that value.

Typetypeof nameExampleDescription
Integer"Integer"2, -42, 0Whole numbers.
Double"Double"3.14, -2.5, 1.0Floating-point numbers.
Boolean"Boolean"true, falseLogical truth values.
Character"Character"'a', 'Z'A single character, written with single quotes.
List"List"[1, 2, 3], "hello"Ordered collection. A string is a list of characters, so it also reports "List".
Set"Set"{1, 2, 3}Unordered collection with no duplicates. See Sets.
Object"Object"#{ key: "v" }#Key-value map. See Objects.
Function"Function"def add(x, y) -> x + yA named function defined with def.
Lambda"Lambda"\x -> x + 1An anonymous function. See Functions.
NaN"NaN"0.0 / 0.0"Not a Number" - an undefined numeric result.
Undefined"Undefined"unset variableThe absence of a value (e.g. an unbound name, or JSON null).
Error"Error"error("boom")A runtime error carried as a value. See Errors as Values.
ADTits own nameCircle(5.0)A user-defined value. Reports the type's own name, e.g. "Shape". See ADTs.

Inspecting a Value's Type

Use :: (or its word form typeof) to ask any value for its type at runtime. The result is a string, so you can compare it or branch on it:

:: 42
-- Output: "Integer"

:: 3.14
-- Output: "Double"

:: true
-- Output: "Boolean"

:: 'a'
-- Output: "Character"

:: [1, 2, 3]
-- Output: "List"

:: {1, 2, 3}
-- Output: "Set"

:: #{ name: "Bern" }#
-- Output: "Object"
Note: A string is a list of characters, so :: "hello" reports "List", not a separate string type. Likewise, integer and double patterns match across the divide - 5 matches 5.0 in a function clause.
Searching for the types you can use inside an ADT? Click here! Bern 2.0

When you declare an ADT, each constructor field is annotated with a type. The names accepted in an adt declaration are written in their short form and are a separate vocabulary from the runtime typeof names above:

In an adt declarationReported by :: asMeaning
Int"Integer"Whole number field.
Double"Double"Floating-point field.
Bool"Boolean"Boolean field.
Char"Character"Single-character field.
String"List"Text field (a list of characters).
List"List"List field.
Set"Set"Set field.
Auto / Anythe value's own typeBern 2.1 Dynamic field: accepts a value of any type, detected automatically. A natural fit for a dynamically typed language.
any namethat nameAnother ADT used as a field type (e.g. Shape).
adt Shape = Circle Double | Rectangle Double Double | Named String
adt Tree  = Leaf Int | Node Tree Tree    -- ADTs can nest other ADTs
adt Box   = Box Auto                     -- the field holds whatever type is passed

Field types are used to check each constructor's arity when the file loads; they are not strictly enforced on the values at runtime, in keeping with Bern's dynamic nature. The Auto type makes that dynamism explicit: use it when a constructor field should accept any value.

Want type-checked variables with the typed pragma? Click here! Bern 2.1

With the typed pragma enabled, you can annotate a variable with a type using name :: Type = value. The value is checked against the declared type when the binding runs; a mismatch is a hard error. This is opt-in - without the pragma, the syntax is not active and :: keeps its usual meaning as the typeof operator.

{--! typed !--}

x :: Integer   = 10
a :: Character = 'a'
name :: String = "bern"
pi :: Double   = 3.14
ok :: Boolean  = true

bad :: Integer = 'a'
-- Error: type mismatch for 'bad': declared Integer but value is Character

The declared type uses the canonical typeof names (Integer, Double, Boolean, Character, String, List, Set, Object), and a few familiar aliases are accepted too: Int, Float, Char, Bool, Text. An ADT type name (e.g. Shape) matches a value built from any of its constructors. The Auto (or Any) type accepts any value:

{--! typed !--}

anything :: Auto = 7
anything :: Auto = "now a string"   -- Auto never complains

adt Shape = Circle Double | Square Double
sh :: Shape = Circle(1.0)           -- a Shape value is accepted

Operators

Beyond the symbols you already know, Bern gives most operations a written-out word form too, so a program can read like prose.

Word Operators

Every symbolic operator also has a written-word form, so programs can read like prose. The symbol and the word are interchangeable.

SymbolWordSymbolWord
+plus==equals (or is)
-minus!=not-equals
*times&&and
/divided-by||or
%modulo!not
>is-greater<>concat
<is-less<|union
>=is-greater-or-equal|>intersect
<=is-less-or-equal</>difference
::typeof)|and-do
:>length=be (assignment)

Loops also accept in as a synonym for : (and for as an alias for loop), and function and case bodies accept returns as a synonym for -> / =.

nums be [1, 2, 3, 4, 5]

3 is-greater 2 and 5 is-less-or-equal 5
-- Output: true

[1, 2] concat [3, 4]
-- Output: [1, 2, 3, 4]

def classify(n) when n is-greater 0 returns "positive"
Note: The words use hyphens (is-greater-or-equal), and resolve the same way as their symbols, at the same precedence.

Scope

Every name in Bern lives in a scope - the region of the program where that binding can be seen. Bern keeps two kinds of scope, and the operator you use to assign a variable decides which one it goes into:

  • = (or the word be) - a local binding, visible only in the current scope.
  • := - a global binding, visible everywhere in the program.

Local Scope (=)

The ordinary name = value assignment creates a binding in the current scope. At the top level of a file or in the REPL, that scope is the file itself. Inside a function, loop, or branch body, the body has its own scope: bindings created there with = are local and disappear when the body finishes - they do not leak back out to the caller.

def f() do
    local = 10        -- bound only inside f
    return 0
end

x = f()
print local           -- undefined: `local` never escaped f

Each call gets a fresh local scope, so locals from one call never bleed into another. This is what makes functions self-contained: a helper can use whatever temporary names it likes without disturbing the names around the call site.

Global Scope (:=)

The := operator writes into a single, program-wide global table. A global assigned anywhere - even deep inside a function - becomes visible to every other scope, and survives after the function that created it has returned.

def f() do
    shared := 20      -- written to the global scope
    return 0
end

x = f()
print shared          -- Output: 20  (the global outlived the call)

Use := when you deliberately want state that outlives a single scope - a counter, a configuration value, or a result several functions need to share. For everything else, prefer =: local bindings keep your program easier to follow because their effects stay contained.

= vs := at a Glance

= (local):= (global)
Where it bindsCurrent scope onlyProgram-wide global table
Visible inside other functionsNoYes
Survives after the scope endsNoYes
Typical useTemporary / working valuesShared, long-lived state

How a Name Is Resolved

When Bern reads a variable, it looks in the global scope first, and only falls back to the local scope if the name is not global. This means that once a name has been bound with :=, a later local = to the same name cannot shadow it - reads keep returning the global value:

g := 1
g = 2                 -- writes a local `g`, but...
print g               -- Output: 1  (the global is found first)
Note: Because globals win during lookup, avoid reusing a global name for an unrelated local value. Pick distinct names, or update the global itself with := if you really mean to change the shared value.

Conditionals

Bern supports conditional execution through if-then-else statements. These allow your code to make decisions based on conditions.

If-Then-Else Statement

The basic conditional structure lets you execute different code based on whether a condition is true or false:

age = 18
if age >= 18 then
    "You are an adult."
else
    "You are a minor."
end
-- Output: "You are an adult."

Else-If Chains

For multiple conditions, use else if to check additional cases in sequence:

score = 85
if score >= 90 then
    "You got an A."
else if score >= 80 then
    "You got a B."
else if score >= 70 then
    "You got a C."
else
    "You need to improve."
end
-- Output: "You got a B."

Example: FizzBuzz

A classic programming challenge demonstrating nested conditionals:

numbers = [1..100]
loop n : numbers do
    if n % 15 == 0 then
        "FizzBuzz"
    else if n % 3 == 0 then
        "Fizz"
    else if n % 5 == 0 then
        "Buzz"
    else
        n
    end
end

Short-Circuit Evaluation Bern 2.0

The boolean operators && and || stop at the first operand that decides the result, so the right-hand side is only evaluated when it is needed.

false && (1 / 0 == 0)
-- Output: false   (the right side never runs)

Loops

Bern uses Odin-inspired loop syntax where loop is the only loop keyword. The behavior changes based on how you use it.

Note: for is accepted everywhere loop is, as a backwards-compatible alias. loop is the preferred spelling.

Repeat Loop

Execute a block a specific number of times:

loop 3 do
    "Hello, Loops!"
end
-- Output: "Hello, Loops!" (three times)

Conditional Loop

Repeat while a condition is true (like a while loop):

counter = 2
loop counter > 0 do
    "Hello, Loops!"
    counter = counter - 1
end
-- Output: "Hello, Loops!" (twice)

Infinite Loop

Create an infinite loop with loop true:

loop true do
    "This runs forever!"
end
Note: Be careful with infinite loops! Make sure you have a way to break out of them in your actual programs.

Loop-In Loop

Iterate over elements in lists, sets, or strings:

text = "Bern"
loop char : text do
    "Current char is: " + char
end
-- Output:
-- "Current char is: B"
-- "Current char is: e"
-- "Current char is: r"
-- "Current char is: n"

Loop-In with Index

Get both the element and its index during iteration:

text = "Bern"
loop char, index : text do
    "Char: " + char + " at index: " + index
end
-- Output:
-- "Char: B at index: 0"
-- "Char: e at index: 1"
-- "Char: r at index: 2"
-- "Char: n at index: 3"

Lists

A list is an ordered collection of elements of the same type. Lists allow duplicates and preserve insertion order. In Bern, strings are also treated as lists of characters.

Declaring Lists

numbers_list = [1,2,3,4,5]
numbers_list
-- Output: [1,2,3,4,5]

Range Syntax

Create lists using Haskell-style range notation:

range_list = [5..10]
-- Output: [5,6,7,8,9,10]

small_range = [1..5]
-- Output: [1,2,3,4,5]

Indexing

Access specific elements using zero-based indexing:

range_list = [5..10]
range_list[3]
-- Output: 8 (fourth element)

List Arithmetic

Arithmetic operations with a scalar apply to every element:

[1,2] + 2
-- Output: [3,4]

[10,20,30] * 2
-- Output: [20,40,60]

Operations between two lists require equal lengths:

[1,2,3] + [4,5,6]
-- Output: [5,7,9]

[10,20] - [5,3]
-- Output: [5,17]

List Set Operations

Lists support set-like operations while preserving order and allowing duplicates:

  • <> - Union (concatenation)
  • </> - Difference (elements in first but not second)
  • |> - Intersection (common elements)
  • <| - Symmetric difference (elements in either but not both)
[1,2,3] <> [3,4,5]
-- Output: [1,2,3,3,4,5]

[1,2,3]  [2,3]
-- Output: [1]

[1,2,3] |> [2,3]
-- Output: [2,3]

[1,2,3] <| [2,3]
-- Output: [1]

Complex List Operations

result = ([1,2,3,4] <> [3..5]) <| ([2,3,4] |> [4,5])  [1]
-- Combines concatenation, symmetric difference,
-- intersection, and regular difference

Negative Indexing Bern 2.0

Negative indices count back from the end of a list or string.

[10, 20, 30][-1]
-- Output: 30
"Bern"[-1]
-- Output: 'n'

Lazy Ranges Bern 2.0

Ranges are lazy: their length is computed instantly and only the elements you actually touch are realised, so even an enormous range is cheap to work with.

big = [1..1000000000]
:> big
-- Output: 1000000000   (instant, nothing is materialised)
take(5, big)
-- Output: [1, 2, 3, 4, 5]

Destructuring Lists (Cons Patterns)

In a pattern - a function clause or a case branch - the syntax [head | tail] splits a list into its first element and the rest. This is called a cons pattern. The name before the | binds to the first element; the name after it binds to a list of everything else.

def head([h | t]) -> h
def tail([h | t]) -> t

head([10, 20, 30])
-- Output: 10
tail([10, 20, 30])
-- Output: [20, 30]

Use _ for any part you don't need, and add an empty-list clause [] so the function is exhaustive (a cons pattern never matches an empty list):

def first([h | _]) -> h
def first([])      -> error("first: empty list")

first([1, 2, 3])
-- Output: 1
first([])
-- Output: an error value

Because the tail is itself a list, cons patterns are the natural way to write recursive functions that walk a list one element at a time:

def sum([])      -> 0
def sum([h | t]) -> h + sum(t)

sum([1, 2, 3, 4])
-- Output: 10

To pull off more than one leading element, nest the pattern - [a | [b | rest]] binds the first two elements as a and b. Sets have the same construct with braces, {head | tail}, though since sets are unordered the "first" element is simply whichever one the set yields first.

Note: the very same [head | tail] syntax also works as an expression - see Cons Expressions below.

Cons Expressions Bern 2.0

In expression position, [head | tail] builds a list by prepending one or more elements onto an existing one - the mirror image of the cons pattern. The values before the | become the new front; the expression after it must evaluate to a list.

[0 | [1, 2, 3]]
-- Output: [0, 1, 2, 3]

[1, 2 | [3, 4]]
-- Output: [1, 2, 3, 4]   (multiple heads are allowed)

x = [9 | [10, 11]]
-- x is [9, 10, 11]       (yes, it's a real expression you can assign)

Prepending is lazy: the tail's spine is never forced, so you can cons onto a huge range and still get an instant result.

big = [0 | [1..1000000000]]
:> big
-- Output: 1000000001   (instant)
big[1]
-- Output: 1

Sets have the matching {head | tail} form, where the tail must be a set.

List Comprehensions Bern 2.0

A list comprehension builds a list from one or more generators (pattern <- source) and optional guards (boolean filters), written as [expression | qualifiers]. It reads as "the expression, for each binding produced by the qualifiers".

[x * x | x <- [1..5]]
-- Output: [1, 4, 9, 16, 25]

[x | x <- [1..10], x % 2 == 0]
-- Output: [2, 4, 6, 8, 10]   (a guard keeps only even x)

Multiple generators nest like loops, producing every combination (the last generator varies fastest):

[a + b | a <- [1, 2], b <- [10, 20]]
-- Output: [11, 21, 12, 22]

A generator's left side is a full pattern, so it can destructure as it iterates. Elements that don't match the pattern are skipped - a handy way to filter and unwrap at once:

[h | [h | _] <- [[1, 2], [], [3]]]
-- Output: [1, 3]   (the empty list has no head, so it's dropped)

Because a string is a list of characters, you can iterate one directly:

[c | c <- "abc"]
-- Output: ['a', 'b', 'c']
Note: a comprehension needs at least one generator. Without a <-, [a | b] is read as a cons expression instead. The classic map/filter/pipe style still works too, and is often clearer for a single transformation: [1..10] )| filter(\x -> x % 2 == 0) )| map(\x -> x * x).

Sets

A set is an unordered collection of unique elements that can be of different types. Sets automatically remove duplicates and don't preserve order.

Declaring Sets

numbers_set = {1,2,3,4,5}
numbers_set
-- Output: {1,2,3,4,5}

mixed_set = {1, "hello", 3.14}
-- Sets can contain different types

Indexing

While sets are unordered, you can still access elements by index:

numbers_set = {1,2,3,4,5}
numbers_set[2]
-- Output: (some element from the set)

Set Arithmetic

Adding to a set inserts elements (if not already present):

{1,2} + 3
-- Output: {1,2,3}

{1,2} + {3,4}
-- Output: {1,2,3,4}

{1,2,3} + 2
-- Output: {1,2,3} (2 already exists)

Subtraction removes elements:

{1,2,3} - 2
-- Output: {1,3}

Set Operations

Mathematical set operations with automatic duplicate removal:

{1,2,3} <> {3,4,5}
-- Union: {1,2,3,4,5}

{1,2,'a',3.5} <> {3,4,'b',1}
-- Union with mixed types: {1,2,'a',3.5,3,4,'b'}

{1,2,3} |> {2,3,4}
-- Intersection: {2,3}

{1,2,3}  {2,3,4}
-- Difference: {1}

{1,2,3} <| {2,3}
-- Symmetric difference: {1}
Remember: Sets automatically remove duplicates and don't guarantee order. If you need ordered data or duplicates, use lists instead.

Objects

Bern supports objects (hashmaps/hashtables) using the #{...}# notation for key-value pairs.

Declaring Objects

obj = #{
    key: "value",
    hello: "world"
}#
obj
-- Output: #{key: "value", hello: "world"}#

Updating Values

Reassign values using bracket notation:

obj["key"] = "new_value"
obj
-- Output: #{key: "new_value", hello: "world"}#

Adding Fields

Add new key-value pairs by assigning to a new key:

obj["new_key"] = "added_value"
obj
-- Output: #{key: "new_value", hello: "world", new_key: "added_value"}#

Nested Objects

Objects can contain other objects. Access nested values with multiple indexing:

obj["test"] = #{
    nested_key: "nested_value"
}#

obj["test"]["nested_key"]
-- Output: "nested_value"

Complete Object Example

person = #{
    name: "Alice",
    age: 25,
    address: #{
        city: "Boston",
        zip: "02101"
    }#
}#

person["address"]["city"]
-- Output: "Boston"

Functions

Bern functions are powerful and flexible, supporting pattern matching, inline arrow syntax, and block bodies with explicit returns.

Simple Function Definition

For one-line functions, use the arrow syntax def name(params) -> expr:

def add(x, y) -> x + y
add(2, 3)
-- Output: 5

def square(x) -> x * x
square(4)
-- Output: 16

Pattern Matching

Define multiple clauses for a function. Bern will use the first matching pattern:

def sign(0) -> "zero"
def sign(n) -> "positive"

sign(0)
-- Output: "zero"

sign(42)
-- Output: "positive"

Wildcards and Literal Matching

Use _ to ignore parameters, or match specific literal values:

def greet("Alice") -> "Hi, Alice!"
def greet(_) -> "Hello, someone else!"

greet("Alice")
-- Output: "Hi, Alice!"

greet("Bob")
-- Output: "Hello, someone else!"

Block-Bodied Functions

For complex functions, use do ... end blocks with explicit return statements:

def sumList(xs) do
    total = 0
    loop n : xs do
        total = total + n
    end
    return total
end

sumList([1,2,3])
-- Output: 6

Lambda Functions

Anonymous functions use backslash syntax and can be assigned to variables or passed as arguments:

inc = \x -> x + 1
pairSwap = \a, b -> [b, a]

inc(10)
-- Output: 11

pairSwap(1, 2)
-- Output: [2, 1]

Higher-Order Functions

Functions can accept other functions as parameters:

def applyTwice(f, x) -> f(f(x))

applyTwice(\n -> n * 2, 5)
-- Output: 20 (5 * 2 * 2)

Guards (when)

A clause matches only when its patterns match and its when guard evaluates to true. If the guard is false, evaluation falls through to the next clause. Guards work on function clauses, lambdas, and case branches.

def classify(n) when n > 0 -> "positive"
def classify(0)            -> "zero"
def classify(n)            -> "negative"

classify(7)
-- Output: "positive"
classify(-3)
-- Output: "negative"

-- Guards in a case expression
def size(n) -> case n is x when x > 100 = "big" | x when x > 10 = "medium" | _ = "small" end
size(50)
-- Output: "medium"

-- Guards on a lambda
check_pos = \x when x > 0 -> "yes"
check_pos(4)
-- Output: "yes"

Negative Literal Patterns

Negative integers and doubles can be matched directly as literal patterns:

def sign(-1) -> "minus one"
def sign(_)  -> "other"

sign(-1)
-- Output: "minus one"

Repeated Variables in Patterns

When the same variable name appears more than once in a pattern, the matched values must be equal for the clause to apply:

def same(x, x) -> "equal"
def same(_, _) -> "different"

same(3, 3)
-- Output: "equal"
same(3, 4)
-- Output: "different"
Note: Numeric patterns match across the integer/double divide: def isFive(5) also matches 5.0.

Auto-Currying Bern 2.0

Calling a function with fewer arguments than it expects returns a partial application that waits for the rest. Disable it per file with the no-curry pragma.

def add(x, y) -> x + y

add2 = add(2)
add2(10)
-- Output: 12

add(2)(40)
-- Output: 42

Exhaustive Matching Bern 2.0

A function whose clauses cannot cover every possible input is reported when the file loads, so partial functions don't fail silently at runtime. Add the missing clause, or opt out per file with the partial pragma.

def head([h|_]) -> h
-- Error: function 'head' is not exhaustive (no clause for [])

def head([h|_]) -> h
def head([]) -> error("head: empty list")
-- OK

Pipe Operator

The )| operator threads a value into a function as its first argument, letting you read data transformations left to right. x )| f is the same as f(x), and x )| f(a, b) is the same as f(x, a, b).

[3, 1, 2] )| reverse
-- Output: [2, 1, 3]   (same as reverse([3, 1, 2]))

[1, 2, 3, 4] )| filter(\x -> x > 2)
-- Output: [3, 4]      (same as filter([1, 2, 3, 4], \x -> x > 2))

Chaining

The operator is left-associative, so pipes chain into a pipeline:

[1, 2, 3, 4] )| filter(\x -> x % 2 == 0) )| map(\x -> x * 10)
-- Output: [20, 40]

Precedence and Lambdas

The pipe binds looser than arithmetic, and you can pipe directly into a lambda:

2 + 3 )| (\n -> n == 5)
-- Output: true   (2 + 3 is evaluated first)
Tip: Pipelines work best with collection-first functions such as map and filter, where the piped collection naturally becomes the first argument.

Custom Operators Bern 2.1

You can define your own infix operators - both symbolic ones like ^ and word ones like dot. A custom operator is just a two-argument function whose name happens to be a symbol or a word, so pattern matching, guards, currying, and use as a plain function all work exactly as they do for any other function.

Defining an operator

Write the definition the way you will call it - the operands on either side of the operator, wrapped in parentheses after def:

def (x ^ 0) -> 1
def (x ^ y) -> x * (x ^ (y - 1))

2 ^ 10
-- Output: 1024

Each def (lhs op rhs) is one clause, so you get multiple clauses, literal patterns, and when guards just like a named function:

def (n ~? 0)            -> "zero"
def (n ~? m) when m > 0 -> "positive"
def (n ~? m)            -> "negative"

5 ~? (-3)
-- Output: "negative"

Fixity: precedence and associativity

When you write an expression without parentheses, two things decide how it groups. You set them with an optional infix line, written in plain English:

infix ^ groups right, binds tighter than *

Precedence ("binds tighter") is who grabs its operands first - the way * goes before + in maths. You position your operator relative to one you already know:

  • binds tighter than <op> - higher precedence than <op>
  • binds looser than <op> - lower precedence than <op>
  • binds like <op> - about the same as <op>

The anchor <op> can be any built-in operator (+ - * / % == < > && || …) or another operator you defined above.

Associativity ("groups") decides which side groups first when the same operator repeats:

You writegroups leftgroups right
a ~ b ~ c(a ~ b) ~ ca ~ (b ~ c)
  • groups left - what most operators want (like -: 10 - 3 - 2 is (10 - 3) - 2).
  • groups right - what power wants (2 ^ 2 ^ 3 is 2 ^ (2 ^ 3)).
  • groups none - repeating it is an error; you must parenthesise (good for comparisons).
Both clauses are optional. Skip the whole infix line and a new operator defaults to groups left, binds like *, so simple cases need only their def clauses.

Operators as plain functions

A symbolic operator becomes an ordinary value by wrapping it in parentheses, Haskell-style, and supports sections:

(^)(2, 3)             -- 8   (prefix call)
pow = (^)             -- store the operator in a variable
double = (^)(2)       -- curried: \y -> 2 ^ y

map([1, 2, 3], (^ 2))   -- [1, 4, 9]   right section: \x -> x ^ 2
map([1, 2, 3], (2 ^))   -- [2, 4, 8]   left section:  \x -> 2 ^ x

A word operator needs none of this - its name is already an identifier, so it works both ways out of the box:

infix dot groups left, binds like *
def (a dot b) -> sum(zipWith(a, b, \p, q -> p * q))

[1, 2, 3] dot [4, 5, 6]     -- 32   (infix)
dot([1, 2, 3], [4, 5, 6])   -- 32   (prefix - it is just a function)

Sharing operators across files

Operators propagate through import exactly like the functions they are. Define an operator once in a library and every file that imports it can use it, infix and all:

lib/mymath.brn

infix ^ groups right, binds tighter than *
def (x ^ 0) -> 1
def (x ^ y) -> x * (x ^ (y - 1))

main.brn

import mymath
2 ^ 10
-- Output: 1024
Symbolic operator names are any run of operator characters - ! # $ % & * + - / : < = > ? @ ^ | ~ - that is not already a built-in operator (+, *, ==, <|, …) or a reserved symbol. So ^, <|>, >>= and <$> all work, and a custom operator never clashes with a built-in. Word operators may be any identifier that is not already a keyword or a built-in word operator.

Imports

Bern allows importing libraries to extend functionality. Use the import keyword to load standard libraries or custom modules.

Importing the Core Library

import core

Importing with an alias

import core as my_core

Core Library Functions

Once imported, you can use functions like map, filter, and type conversions:

map([1,2,3,4,5], \x -> x + 2)
-- Output: [3,4,5,6,7]

def isEven(x) -> x % 2 == 0
filter([1,2,3,4,5,6,7,8,9,10], isEven)
-- Output: [2,4,6,8,10]

In the case of aliases, you should specify the alias, followed by a semicollon and the name of the given:

import core as c
c:map([1,2,3], \x -> x * 2)
-- Output: [2,4,6]

Type Conversion

The core library provides conversion functions for handling input:

import core
age_text = input("Type your age: ")
age = to_int(age_text)
"Your age in 5 years: " + (age + 5)

User Input

Capture user input with the input() function, similar to Python.

Basic Input

name = input("Type your name: ")
name
-- Displays whatever the user typed

Converting Input

By default, input() returns text. Convert to other types using core library functions:

import core
age_text = input("Type your age: ")
age = to_int(age_text)
"You'll be " + (age + 10) + " in 10 years"
Ready for more? You now know enough to write real programs - and one that asks for input() makes a great first file to keep. Save your code in a file ending in .brn and run it with bern myfile.brn (or load it into the REPL with :load myfile.brn). From here the guide gets slightly more advanced - take it at your own pace, and keep that REPL handy.

Algebraic Data Types (ADTs)

ADTs allow you to define custom data types with multiple constructors, similar to enums or tagged unions in other languages.

Defining ADTs

Use the adt keyword followed by type name and constructors:

adt Shape = Circle Double | Rectangle Double Double

Each constructor can have zero or more fields of specific types. See Types in ADT Declarations for the full list of field-type names you can use (Int, Double, Bool, Char, String, List, Set, Auto, or another ADT).

Multi-line Declarations Bern 2.1

A declaration with many constructors can be split across several lines, one constructor per line, in the Haskell style. Put the | at the start of each continuation line:

adt Expression = Num Double
    | Bool Bool
    | Add Expression Expression
    | Sub Expression Expression
    | If  Expression Expression Expression

This is purely cosmetic - the single-line and multi-line forms are equivalent, and you can mix both styles freely.

Creating Instances

Call constructors like functions to create instances:

c = Circle(5.0)
r = Rectangle(3.0, 4.0)

Pattern Matching with ADTs

Destructure ADTs in function definitions to access their fields:

def area(Circle(r)) -> 3.14159 * r * r
def area(Rectangle(w, h)) -> w * h

area(c)
-- Output: 78.53975 (π * 5²)

area(r)
-- Output: 12.0 (3 * 4)

Iterative Data Types

You can define Algebraic Data Types as iterable with the iterative keyword:

adt iterative Shape = Circle Double | Rectangle Double Double

The Maybe Type

A common ADT pattern for representing optional values:

adt Maybe = Just Int | None

def safeDivide(n, 0) -> None()
def safeDivide(n, m) -> Just(n / m)

safeDivide(10, 3)
-- Output: Just(3)

safeDivide(5, 0)
-- Output: None()
Use Case: The Maybe type is perfect for operations that might fail, allowing you to handle success and failure cases explicitly without exceptions.

Errors as Values Bern 2.0

By default, a runtime error no longer crashes the program. Instead it becomes a first-class error value that propagates through expressions and can be inspected. The program keeps running.

import core

bad = [1, 2, 3][99]      -- out of bounds
is_error(bad)
-- Output: true

"the program keeps running"
-- Output: "the program keeps running"

This was implemented to let Bern be more like other languages, such as Go or Odin. That said I do believe that you should really be using Monads and ADTs for error handling!

Raising and testing errors

You can use the following functions defined in core for better error handling:

error(message) → error value

Create a recoverable error value.

is_error(value) → boolean

Test whether a value is an error.

def safe_head([]) -> error("empty list")
def safe_head([h|_]) -> h

result = safe_head([])
if is_error(result) then
    "no head"
else
    result
end
Note: Use the abort-on-error pragma to restore the classic crash-on-error behaviour.

Global Functions

Bern provides built-in functions for file operations and system utilities.

File Operations

  • read_file(path) - Read file contents as a string
  • write_file(path, content) - Write content to a file
  • get_current_dir() - Get the current working directory
content = read_file("myfile.txt")
write_file("output.txt", "Hello, Bern!")
write_file("data.txt", content + "\nNew line")

System Information

Note: Only available in Bern versions 0.1.2 and above.

Returns the system the user is running. Useful for bindings.

os = get_host_machine()
-- Returns OS/system information

fmap

Note: Only available in Bern versions 0.1.2 and above.

Applies a function to each element of a collection. Used too in Algebraic Data Types (ADTs) to modify contained values without breaking functorial context.

ast Maybe = Just Int | None
fmap(Just(5), \x -> x * 2)

-- Output: Just(10)

keys

Returns the list of an object's keys, in insertion order. Useful for iterating over or serializing objects.

obj = #{ name: "Bern", version: 3 }#
keys(obj)
-- Output: ["name", "version"]

Standard Library Reference

Bern ships with a full standard library plus bundled vendor modules. Each library now has its own page - with a complete function reference and worked examples, in English and Portuguese.

→ Browse all libraries

Standard libraries are imported by name (import math); vendor modules use their path (import vendor/csv).

Standard library

  • core - list operations, folds, higher-order helpers, conversions
  • math - constants, powers, roots, logs, trigonometry
  • strings - slice, split, join, case conversion, search
  • random - random numbers, choices, strings
  • assert - assertion helpers for tests
  • path - join, normalize, inspect filesystem paths
  • url - encoding, query strings, URL parsing/building
  • crypto - tokens, checksums, UUID-like IDs
  • json - parse and serialize JSON

Vendor modules

C Bindings

Note: Only available in Bern versions 0.1.2 and above.

You can call C functions directly from Bern using the foreign function interface (FFI). This allows you to leverage existing C libraries and system calls.

For this, you need to declare the C function signature using the foreign keyword, specifying the library, its function name, argument types and return type.

foreign example("lib.so", "int") -> "void"

Recommendation: Use get_host_machine() inside an auxiliary function to get the correct library path for multiple operating systems.

def get_path() do
    os = get_host_machine()
    if os == "mingw64" || os == "mingw32" then
        return "windows_lib.dll"
    else
        return "linux_lib.so.6"
    end
end

Bundling to .exe

The build command packages a script into a single, self-contained executable. It scans the script's import statements (transitively) and embeds those library files alongside the script, so the resulting program runs even where no lib/ folder is present.

bern build app.brn
-- Creates app.exe

bern build app.brn -o myprogram.exe
-- Creates myprogram.exe

The bundled executable runs the embedded script directly:

./app.exe
-- Runs app.brn with its bundled libraries
How it works: the script and its libraries are appended to a copy of the interpreter as a blob. On startup the interpreter detects the blob, extracts it to a temporary directory, and runs the embedded main.brn from there. Your working directory is left untouched.

Pragmas Bern 2.0

Pragmas are file-level directives that tune how Bern behaves. Write them as a magic comment, usually at the top of a file:

{--! strict-types !--}
{--! immutable !--}

The table below is the quick reference. For an explanation, example, and use-case of every pragma, see the → Pragmas page.

PragmaEffect
impure-listslists may hold mixed element types
impure-setsset operations keep duplicates
strict-typesforbid implicit string/number coercion
strict-arithmeticdivision by zero is an error, not NaN
immutablereassigning an existing variable is an error
no-evalbare expressions don't auto-print (print() still works)
show-typesauto-printed values show their type
safe-indexout-of-bounds indexing returns undefined
no-undefinedreading an unbound variable is an error
start-on-oneindexing starts at 1
mainrun main() after the file loads
no-currydisable auto-currying
abort-on-errorcrash on runtime errors instead of producing error values
partialallow non-exhaustive functions
no-written-operatorsswitch off the written-word operators, freeing names like length and plus for use as variables
typedBern 2.1 enable type-annotated variable declarations, name :: Type = value, checked at runtime (see Typed Variable Declarations)