Bern by Example

A guided, in-depth tour of the language. Every snippet is runnable - paste it into the REPL or save it as a .brn file. Wherever symbols appear, use the Show written form button to flip the same code into its plain-English equivalent.

How to read this page: short snippets show one idea at a time, with the expected output written as a comment (-- Output: ...). For a terse reference of every feature and the full standard library, see the Documentation. For a quick taste, the homepage examples are a condensed version of this page.

Introduction

Bern is a small, functionally-inspired language built around a single idea: code should read the way you think about the problem. Three habits will make the rest of this page click:

  1. Evaluation implies output. Any expression you write at the top level is evaluated and printed automatically - there is no print ceremony for quick experiments.
  2. Everything is an expression. if, case, and function bodies all produce a value, so they can be assigned, returned, or piped.
  3. Symbols and words are interchangeable. a + b and a plus b are the same program. Beginners can write words; once the symbols feel natural, switch freely.

Literals & Automatic Output

Write a value on its own line and Bern evaluates and prints it. There is no main function to set up and nothing to import for the basics.

"Hello, World!"
23
3.14
true

Each of these four lines produces output in order. Strings support the usual escape sequences, and you can build new strings with +:

"Bern" + " " + "rocks"
-- Output: "Bern rocks"

"Line one\nLine two"
-- Output: two lines, because \n is a newline
Note: A single character in single quotes ('n') is a character, while double quotes ("n") make a one-character string. Indexing a string returns characters.

Variables & Types

Bind a name to a value with name = value. Bern infers the type; you never annotate it. The four core scalar types are integers, doubles, strings, and booleans.

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

Reading a variable is just writing its name. Because evaluation implies output, the value prints:

helloworld
-- Output: "Hello, World!"

integer + 40
-- Output: 42

Type Checking & Length

Two prefix operators let you inspect values:

  • :: (also written typeof) returns the type of a value as a string.
  • :> (also written length) returns the length of a list, set, or string.
:: 2
-- Output: "Int"

:: "hello"
-- Output: "String"

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

:> "hello"
-- Output: 5

Arithmetic, Comparison & Logic

Arithmetic works as you would expect. Comparisons return booleans, and the logical operators && / || short-circuit - the right-hand side is skipped entirely when the left already decides the result.

17 % 5            -- Output: 2   (modulo / remainder)
2 + 3 * 4         -- Output: 14  (usual precedence)

3 > 2             -- Output: true
5 >= 5            -- Output: true

false && (1 / 0)  -- Output: false  (right side never runs, so no error)
true  || (1 / 0)  -- Output: true

Negative indexing counts from the end of a list or string - -1 is the last element:

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

Word Operators

Every symbol has a plain-English synonym, so a program can read like a sentence. The symbol and the word are identical to the parser - pick whichever is clearer, and mix them freely.

SymbolWordSymbolWord
+plus==equals / is
-minus!=not-equals
*times&&and
/divided-by||or
%modulo!not
>is-greater<>concat
>=is-greater-or-equal)|and-do
=be->returns / such-that

The same example, twice. Press Show written form on either block to confirm they are interchangeable:

3 > 2 && 5 <= 5
[1, 2] <> [3, 4]
total = 10 + 5
3 is-greater 2 and 5 is-less-or-equal 5
[1, 2] concat [3, 4]
total be 10 plus 5

Loops also accept in as a synonym for :, and function bodies accept returns in place of ->.

Conditionals

The if / then / else / end form chooses between branches. Chain extra cases with else if.

age = 18
if age >= 18 then
    "You are an adult."
else
    "You are a minor."
end
-- Output: "You are an adult."
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."

Conditionals nest naturally. A classic FizzBuzz over a range:

loop n : [1..15] 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

if as an Expression

Because if produces a value, you can use it directly inside a function body or assignment - no temporary variable needed.

def sign(n) -> if n > 0 then "positive" else if n < 0 then "negative" else "zero" end

sign(7)    -- Output: "positive"
sign(-3)   -- Output: "negative"
sign(0)    -- Output: "zero"

It even works inline, with no function around it at all:

label = if true then 1 else 2 end
-- label is 1

Loops

Inspired by Odin, Bern has a single loop keyword, loop. What you put after it changes how it behaves. (for is accepted everywhere as a backwards-compatible alias.)

Repeat loop

Give it a number to repeat a body that many times.

loop 3 do
    "Hello, Loops!"
end

Conditional loop

Give it a boolean condition and it repeats while that condition stays true - this is Bern's while.

counter = 2
loop counter > 0 do
    "Hello, Loops!"
    counter = counter - 1
end

Infinite loop

loop true loops forever - useful for servers and REPL-style programs (stop it with break or by exiting).

loop true do
    "Hello!"
end

Loop-in loop

Iterate the elements of a list, set, or string with : (or the word in).

text = "Bern"
loop char : text do
    "Current char is: " + char
end

Loop-in with an index

Ask for a second binding and Bern hands you the position alongside each element.

text = "Bern"
loop char, index : text do
    "Char " + char + " is at index " + index
end

Defining Functions

The simplest functions are one-liners: def name(params) -> expression. The expression after the arrow is the return value.

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

For multi-step logic use a do ... end block and an explicit return.

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

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

Pattern Matching

A function can have several clauses. Bern tries them top to bottom and runs the first whose pattern matches the arguments. This replaces most chains of if.

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

sign(0)    -- Output: "zero"
sign(42)   -- Output: "positive"

Patterns can be literals (matched exactly), variables (bind anything), or _ (the wildcard, which matches and ignores).

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

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

Negative numbers work as literal patterns, and a variable repeated in one pattern forces the matched values to be equal:

def describe(-1) -> "minus one"
def describe(_)  -> "something else"

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

same(3, 3)   -- Output: "equal"
same(3, 4)   -- Output: "different"

Lists destructure with the [head | tail] pattern - the basis of recursive functions over lists:

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

first([10, 20])   -- Output: 10
first([])         -- Output: an error value (see "Errors as Values")

Guards

Add when <condition> after the parameters to make a clause apply only when the condition is also true. Guards let one set of patterns split on a value's contents.

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

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

Guards work with block bodies and with lambdas too:

def grade(n) when n >= 90 do return "A" end
def grade(n) when n >= 80 -> "B"
def grade(_)              -> "C"

check_pos = \x when x > 0 -> "yes"
check_pos(4)   -- Output: "yes"

Lambdas & Higher-Order Functions

Functions are ordinary values. Write an anonymous one with \params -> expression, store it in a variable, pass it around, or return it.

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

inc(10)         -- Output: 11
pairSwap(1, 2)  -- Output: [2, 1]

A higher-order function takes another function as an argument. map and filter from core are the everyday examples:

import core

map([1, 2, 3, 4, 5], \x -> x * 2)
-- Output: [2, 4, 6, 8, 10]

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

Currying & Partial Application

Call a function with fewer arguments than it expects and Bern returns a new function waiting for the rest. This makes it easy to build specialised helpers on the fly.

import core

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

add2 = add(2)     -- a new function: "add 2 to its argument"
add2(10)          -- Output: 12
add(2)(40)        -- Output: 42  (chain the calls directly)

Partial application shines when feeding helpers into map and friends:

import core

def add(x, y) -> x + y
map([10, 20, 30], add(1))
-- Output: [11, 21, 31]

The Pipe Operator

x )| f passes x as the first argument of f - that is, x )| f is exactly f(x). It lets data transformations read left to right instead of inside-out.

import core

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

When the function needs more arguments, the piped value fills the first slot and you supply the rest:

import core

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

Pipes are left-associative, so chaining builds a readable pipeline:

import core

[1, 2, 3, 4] )| filter(\x -> x % 2 == 0) )| map(\x -> x * 10)
-- Output: [20, 40]
Precedence: the pipe binds looser than arithmetic, so in 2 + 3 )| is_five the 2 + 3 is evaluated first, then piped. You can also pipe straight into a lambda: 5 )| (\x -> x + 1).

Lists & Ranges

Lists are ordered collections written with square brackets. The range syntax [a..b] builds a list from a to b inclusive.

numbers = [1, 2, 3, 4, 5]
range = [5..10]
-- range is [5, 6, 7, 8, 9, 10]

Bern overloads arithmetic and set-theory operators for lists:

[1, 2] + 2             -- Output: [3, 4]      (adds to each element)
[1, 2] <> [3, 4, 5]    -- Output: [1, 2, 3, 4, 5]   (concatenation)
[1, 2, 3] <| [3, 4, 5] -- Output: [1, 2, 3, 4, 5]   (union, no duplicates)
OperatorWordMeaning
<>concatJoin two collections end to end
<|unionAll elements of both, deduplicated
|>intersectOnly elements in both
</>differenceElements in the first but not the second

Sets

Sets use curly braces and automatically keep their elements unique. They support the same set-theory operators as lists.

{1, 2} + 3              -- Output: {1, 2, 3}   (add an element)
{1, 2, 3} |> {2, 3, 4}  -- Output: {2, 3}      (intersection)
{1, 2, 3}  {2, 3, 4} -- Output: {1}         (difference)

Objects (Hashmaps)

Objects map keys to values using the #{ ... }# notation.

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

Index with brackets to read, reassign, or add new keys:

obj["key"] = "new_value"      -- reassign an existing key
obj["new_key"] = "added"      -- add a brand-new key

Objects nest, and you can chain indices to reach inside:

obj["inner"] = #{ nested_key: "nested_value" }#
obj["inner"]["nested_key"]
-- Output: "nested_value"

Algebraic Data Types

An ADT defines a type as a choice between several constructors, each carrying its own fields. Declare one with adt:

adt Shape = Circle Double | Rectangle Double Double

Constructors are called like functions to build values:

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

Pattern matching destructures them, pulling the fields out by name. This is where ADTs and multi-clause functions combine beautifully:

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

area(c)   -- Output: 78.53975
area(r)   -- Output: 12.0

The Maybe pattern

A common use of ADTs is modelling "a value, or nothing" safely - no null surprises. Here a division returns None instead of crashing on divide-by-zero:

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

case Expressions

When you want pattern matching inside an expression rather than across function clauses, use case value is pattern = result | ... end. Each branch can carry its own guard.

adt Shape = Circle Double | Square Double

def check(v) -> case v is
      Circle(_) = "It's a Circle!"
    | Square(_) = "It's a Square!"
    | _         = "Unknown"
    end

check(Circle(5.0))   -- Output: "It's a Circle!"
check(Square(2.0))   -- Output: "It's a Square!"

Branches can bind fields and return them, or match on the result of :: (typeof). Guards use the same when keyword as function clauses:

def size(n) -> case n is
      x when x > 100 = "big"
    | x when x > 10  = "medium"
    | _              = "small"
    end

size(500)   -- Output: "big"
size(50)    -- Output: "medium"
size(5)     -- Output: "small"

JSON

The json library parses JSON text into Bern values and serializes them back.

import json

person = json_parse("{\"name\": \"Bern\", \"version\": 3}")
person["name"]      -- Output: "Bern"
person["version"]   -- Output: 3

JSON objects become Bern objects; JSON null becomes Undefined (test it with is_null); and json_stringify turns a value back into a string:

import json

is_null(json_parse("null"))
-- Output: true

json_stringify(json_parse("{\"items\": [1, 2, 3], \"ok\": true}"))
-- Output: a JSON string
Note: a JSON array maps to a Bern Set. A value that repeats collapses into a sub-list of its copies, so set theory stays intact while multiplicity is preserved - e.g. [1, "a", 34.2, "a"] parses to {1, ["a", "a"], 34.2}, and round-trips back to the original array.

Errors as Values

Instead of crashing, runtime failures in Bern produce an error value you can inspect with is_error. This keeps a program running and lets you decide what to do.

import core

is_error(undefined_fn(1))   -- Output: true   (no such function)
is_error([1, 2, 3][99])     -- Output: true   (index out of bounds)
is_error(head([]))          -- Output: true   (no matching clause)
is_error(42)                -- Output: false  (an ordinary value)

Errors are "poison": they propagate through operators, so a single check at the end tells you whether the whole computation succeeded. You can also build your own with error(...).

import core

is_error(undefined_fn(1) + 100)   -- Output: true   (poison spreads through +)

oops = error("boom")
is_error(oops)                    -- Output: true

-- and normal work continues unaffected afterwards
7 * 6                             -- Output: 42

Lazy Ranges

Ranges are lazy: [1..1000000000] is created instantly and only computes elements as you ask for them. Length, indexing, and take are all O(1) or proportional to what you actually use.

import core

big = [1..1000000000]
:> big          -- Output: 1000000000   (instant, no allocation)
big[5]          -- Output: 6
take(5, big)    -- Output: [1, 2, 3, 4, 5]

Small ranges behave exactly like ordinary lists, so you never have to think about which one you have:

import core

[1..5]                      -- Output: [1, 2, 3, 4, 5]
map([1..4], \x -> x * 2)    -- Output: [2, 4, 6, 8]

Imports

Pull in extra functionality with import. The core library holds the everyday higher-order helpers like map, filter, and reverse.

import core

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], isEven)
-- Output: [2, 4, 6]

Other libraries include math, strings, random, json, assert, and more - see the standard library reference for the full list.

User Input

input() reads a line from the user, much like Python's input. It always returns text; convert it with helpers from core such as to_int.

name = input("Type your name: ")
name
-- Output: whatever you typed, as a string
import core

age_text = input("Type your age: ")
age = to_int(age_text)
"In five years you'll be " + (age + 5)