Install it, open the REPL, and follow along - from your first expression to your own programs.
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.
iex (irm https://bern-lang.github.io/Bern/install/install_bern.ps1)
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.
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.
_ VariableThe result of the last expression is bound to _, so you can build on it:
[bern]: 1 + 2 3 [bern]: _ * 10 30
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
| Command | Action |
|---|---|
:help, :h | Show the list of commands |
:load <file>, :l | Load and run a .brn file into the current session |
:reset | Clear local definitions |
:quit, :q | Exit (or press Ctrl-D) |
Command history is available with the up/down arrows and is saved between sessions.
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.
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 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
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.
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
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 stringtypeof_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.
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.
Every value in Bern is one of the following. The typeof name column is the exact string that :: returns for that value.
| Type | typeof name | Example | Description |
|---|---|---|---|
| Integer | "Integer" | 2, -42, 0 | Whole numbers. |
| Double | "Double" | 3.14, -2.5, 1.0 | Floating-point numbers. |
| Boolean | "Boolean" | true, false | Logical 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 + y | A named function defined with def. |
| Lambda | "Lambda" | \x -> x + 1 | An anonymous function. See Functions. |
| NaN | "NaN" | 0.0 / 0.0 | "Not a Number" - an undefined numeric result. |
| Undefined | "Undefined" | unset variable | The 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. |
| ADT | its own name | Circle(5.0) | A user-defined value. Reports the type's own name, e.g. "Shape". See ADTs. |
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"
:: "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.
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 declaration | Reported by :: as | Meaning |
|---|---|---|
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 / Any | the value's own type | Bern 2.1 Dynamic field: accepts a value of any type, detected automatically. A natural fit for a dynamically typed language. |
| any name | that name | Another 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.
typed pragma? Click here! Bern 2.1With 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
Beyond the symbols you already know, Bern gives most operations a written-out word form too, so a program can read like prose.
Every symbolic operator also has a written-word form, so programs can read like prose. The symbol and the word are interchangeable.
| Symbol | Word | Symbol | Word |
|---|---|---|---|
+ | 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"
is-greater-or-equal), and resolve the same way as their symbols, at the same precedence.
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.=)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.
:=)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 binds | Current scope only | Program-wide global table |
| Visible inside other functions | No | Yes |
| Survives after the scope ends | No | Yes |
| Typical use | Temporary / working values | Shared, long-lived state |
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)
:= if you really mean to change the shared value.
Bern supports conditional execution through if-then-else statements. These allow your code to make decisions based on conditions.
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."
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."
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
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)
Bern uses Odin-inspired loop syntax where loop is the only loop keyword. The behavior changes based on how you use it.
for is accepted everywhere loop is, as a backwards-compatible alias. loop is the preferred spelling.
Execute a block a specific number of times:
loop 3 do
"Hello, Loops!"
end
-- Output: "Hello, Loops!" (three times)
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)
Create an infinite loop with loop true:
loop true do
"This runs forever!"
end
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"
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"
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.
numbers_list = [1,2,3,4,5] numbers_list -- Output: [1,2,3,4,5]
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]
Access specific elements using zero-based indexing:
range_list = [5..10] range_list[3] -- Output: 8 (fourth element)
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]
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]
result = ([1,2,3,4] <> [3..5]) <| ([2,3,4] |> [4,5]) > [1] -- Combines concatenation, symmetric difference, -- intersection, and regular difference
Negative indices count back from the end of a list or string.
[10, 20, 30][-1] -- Output: 30 "Bern"[-1] -- Output: 'n'
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]
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.
[head | tail] syntax also works as an expression - see Cons Expressions below.
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.
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']
<-, [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).
A set is an unordered collection of unique elements that can be of different types. Sets automatically remove duplicates and don't preserve order.
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
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)
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}
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}
Bern supports objects (hashmaps/hashtables) using the #{...}# notation for key-value pairs.
obj = #{
key: "value",
hello: "world"
}#
obj
-- Output: #{key: "value", hello: "world"}#
Reassign values using bracket notation:
obj["key"] = "new_value"
obj
-- Output: #{key: "new_value", hello: "world"}#
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"}#
Objects can contain other objects. Access nested values with multiple indexing:
obj["test"] = #{
nested_key: "nested_value"
}#
obj["test"]["nested_key"]
-- Output: "nested_value"
person = #{
name: "Alice",
age: 25,
address: #{
city: "Boston",
zip: "02101"
}#
}#
person["address"]["city"]
-- Output: "Boston"
Bern functions are powerful and flexible, supporting pattern matching, inline arrow syntax, and block bodies with explicit returns.
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
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"
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!"
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
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]
Functions can accept other functions as parameters:
def applyTwice(f, x) -> f(f(x)) applyTwice(\n -> n * 2, 5) -- Output: 20 (5 * 2 * 2)
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 integers and doubles can be matched directly as literal patterns:
def sign(-1) -> "minus one" def sign(_) -> "other" sign(-1) -- Output: "minus one"
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"
def isFive(5) also matches 5.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
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
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))
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]
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)
map and filter, where the piped collection naturally becomes the first argument.
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.
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"
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 write | groups left → | groups right → |
|---|---|---|
a ~ b ~ c | (a ~ b) ~ c | a ~ (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).infix line and a new operator defaults to groups left, binds like *, so simple cases need only their def clauses.
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)
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:
infix ^ groups right, binds tighter than * def (x ^ 0) -> 1 def (x ^ y) -> x * (x ^ (y - 1))
import mymath 2 ^ 10 -- Output: 1024
! # $ % & * + - / : < = > ? @ ^ | ~ - 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.
Bern allows importing libraries to extend functionality. Use the import keyword to load standard libraries or custom modules.
import core
import core as my_core
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]
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)
Capture user input with the input() function, similar to Python.
name = input("Type your name: ")
name
-- Displays whatever the user typed
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"
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.
ADTs allow you to define custom data types with multiple constructors, similar to enums or tagged unions in other languages.
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).
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.
Call constructors like functions to create instances:
c = Circle(5.0) r = Rectangle(3.0, 4.0)
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)
You can define Algebraic Data Types as iterable with the iterative keyword:
adt iterative Shape = Circle Double | Rectangle Double Double
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()
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!
You can use the following functions defined in core for better error handling:
Create a recoverable error value.
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
abort-on-error pragma to restore the classic crash-on-error behaviour.
Bern provides built-in functions for file operations and system utilities.
read_file(path) - Read file contents as a stringwrite_file(path, content) - Write content to a fileget_current_dir() - Get the current working directorycontent = read_file("myfile.txt")
write_file("output.txt", "Hello, Bern!")
write_file("data.txt", content + "\nNew line")
Returns the system the user is running. Useful for bindings.
os = get_host_machine() -- Returns OS/system information
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)
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"]
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.
Standard libraries are imported by name (import math); vendor modules use their path (import vendor/csv).
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
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
main.brn from there. Your working directory is left untouched.
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.
| Pragma | Effect |
|---|---|
impure-lists | lists may hold mixed element types |
impure-sets | set operations keep duplicates |
strict-types | forbid implicit string/number coercion |
strict-arithmetic | division by zero is an error, not NaN |
immutable | reassigning an existing variable is an error |
no-eval | bare expressions don't auto-print (print() still works) |
show-types | auto-printed values show their type |
safe-index | out-of-bounds indexing returns undefined |
no-undefined | reading an unbound variable is an error |
start-on-one | indexing starts at 1 |
main | run main() after the file loads |
no-curry | disable auto-currying |
abort-on-error | crash on runtime errors instead of producing error values |
partial | allow non-exhaustive functions |
no-written-operators | switch off the written-word operators, freeing names like length and plus for use as variables |
typed | Bern 2.1 enable type-annotated variable declarations, name :: Type = value, checked at runtime (see Typed Variable Declarations) |