Changelog

What's new in each release of the Bern language.

Bern 2.1 latest June 2026

Bern 2.1 builds on the 2.0 foundation: user-defined operators, opt-in type annotations, friendlier multi-line declarations, a lexer fix for identifiers that start with a keyword, and a much larger parser-combinator toolkit. The website's library reference was also rebuilt around one page per library.

Language

User-defined operators. You can now declare your own infix operators - symbolic ones like ^ and word ones like dot - with def (x ^ y) -> .... They are ordinary two-argument functions under the hood, so pattern matching, guards and currying just work, and a symbolic operator can be used as a plain value ((^)) or a section ((^ 2), (2 ^)). An optional, plain-English infix line sets fixity - infix ^ groups right, binds tighter than * - and operators propagate through import just like functions, so you can define them once in a library.

infix ^ groups right, binds tighter than *

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

2 ^ 10                  -- 1024
2 * 3 ^ 2               -- 18  (^ binds tighter than *)
map([1, 2, 3], (^ 2))     -- [1, 4, 9]  (a section)

Typed variable declarations. With the new typed pragma, a binding can carry a type: name :: Type = value. The value is checked against the declared type when the binding runs, and a mismatch is a hard error, so a typed file fails loudly rather than drifting. It is fully opt-in - without the pragma the syntax is inactive and :: keeps its usual meaning as the typeof operator.

{--! typed !--}

count :: Integer = 10
name  :: String  = "bern"
ratio :: Double  = 3.14
-- count :: Integer = "oops"   -- error: type mismatch

The declared type accepts the canonical typeof names (Integer, Double, Boolean, Character, String, List, Set, Object), the familiar aliases Int, Float, Char, Bool, Text, and any ADT type name (which matches a value built from any of that type's constructors).

The Auto / Any type. A dynamic type that accepts a value of any type - the explicit way to say "anything goes" in a dynamically typed language. It works both as a typed-variable annotation and as an ADT field type.

{--! typed !--}
anything :: Auto = 7
anything :: Auto = "now a string"   -- Auto never complains

adt Box = Box Auto                  -- a field that holds any value

Multi-line type declarations. A type with many constructors can be split across lines, one constructor per line, with the | at the start of each continuation line - Haskell style. It is purely cosmetic; single-line and multi-line forms are equivalent.

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

Keyword-prefix identifiers. Names that merely start with a reserved word are now tokenised as a single identifier instead of the keyword plus leftover text. Variables, functions, parameters and ADT names like truevar, island, ending, define or importance finally behave the way you would expect.

island = 3            -- one identifier, not `is` + `land`
def define(x) -> x + 1
truevar = true        -- the literal still parses where a value is expected

Libraries

BernParsec friendly aliases. The bundled vendor/bernparsec parser-combinator library now ships a full set of plain-English names that forward to the megaparsec-style core: literalChar, text, charIn, digit, letter, zeroOrMore, oneOrMore, separatedBy, keepLeft/keepRight, firstOf, orTry, integerNumber, decimalNumber, inParens, peek, deferred, chainLeft, run, quickParse, prettyError, and more. Mix the two styles freely.

BernParsec operators. It now also ships megaparsec-style infix operators - <$> (map), <*> (applicative), <* / *> (keep left / right), <|> (choice), >>= (bind) and <?> (label) - built on the new user-defined operators, so a grammar reads almost like the thing it describes: char('$') *> num, or string("if") <|> string("else").

import vendor/bernparsec

quickParse(separatedBy(digits(), literalChar(',')), "1,2,33")
-- [1, 2, 33]

BernParsec as data (ADT form). Parsers, results and optional values can also be expressed as data through the BPParser ADT (the BP* constructors), with BPOk/BPErr results and BPJust/BPNothing options. Run a parser through the ADT path with parseADT / runParserADT when you want a result you can pattern-match on.

Documentation & website

One page per library. The standard library and the bundled vendor modules each have their own reference page now - in English and Portuguese - with every function documented by example and a "putting it together" section that combines them. A new Libraries hub links them all.

Cleaner reading experience. Breadcrumbs were added across the docs, and the whole site got a lighter, more minimalist pass: thinner rules, neutral table headers, and softer accents.

Bern 2.0 June 2026

Bern 2.0 is a major release. It turns the interpreter into a configurable language: a set of pragmas lets you opt into stricter or looser behaviour per file, and several long-standing rough edges are now sensible defaults. It also adds JSON, written-word operators, currying, lazy ranges, and recoverable errors.

New default behaviours

Errors as values. A runtime error no longer aborts the whole program. Instead it becomes a first-class value that you can store, pass around, and inspect. Out-of-bounds indexing, failed lookups, and similar mistakes all produce an error value that quietly propagates through the rest of an expression, so the program keeps running. Test any value with is_error, and raise your own with error. If you prefer the old crash-on-failure behaviour, the abort-on-error pragma brings it back.

import core

bad = [1, 2, 3][99]           -- out of bounds, but no crash
is_error(bad)                 -- true
"the program keeps running"   -- this line still prints

def safe_head([]) -> error("empty list")
def safe_head([h|_]) -> h
is_error(safe_head([]))       -- true
safe_head([10, 20])           -- 10

Auto-currying. Calling a function with fewer arguments than it declares no longer fails. Bern returns a new function that remembers the arguments you already supplied and waits for the rest, which makes partial application and point-free pipelines natural. Turn it off per file with the no-curry pragma.

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

add2 = add(2)                 -- a partial application, waiting for one more argument
add2(10)                      -- 12
add(2)(40)                    -- 42 (the same call, written in one line)

Lazy ranges. Ranges are now lazy. Writing a range does not allocate its elements, so even an astronomically large range is created instantly. Its length is known in O(1) time, and elements are only computed when something actually asks for them.

big = [1..1000000000]         -- created instantly, nothing materialised yet
:> big                        -- 1000000000 (length is O(1))
take(5, big)                  -- [1, 2, 3, 4, 5] (only these five are realised)

Exhaustive matching. When a function is defined by several clauses, Bern now checks at load time that those clauses cover every possible input. A function that could be called with an argument none of its clauses match is reported up front, before it ever runs. The check understands lists, booleans, sets and algebraic data types - a match that covers every constructor of an ADT counts as exhaustive, the same way it does in Haskell. If a partial function is intentional, opt out for the whole file with the partial pragma.

-- reported at load time: this clause cannot match every input
def describe(0) -> "zero"

-- declare the intent explicitly to silence the check
{--! partial !--}
def describe(0) -> "zero"

Short-circuit logic. The && and || operators now short-circuit. The right-hand side is only evaluated when it can still change the result, so an inline guard against something like division by zero is safe to write.

false && (1 / 0 == 0)   -- false, and the risky right-hand side never runs
true || (1 / 0 == 0)   -- true, evaluated the same lazy way

Negative indexing. Indexing accepts negative positions that count backwards from the end of a list, so you can reach the last element without first measuring the length.

[10, 20, 30][-1]        -- 30 (the last element)
[10, 20, 30][-2]        -- 20 (second from the end)

Reserved names are protected. Assigning to a keyword or to a written-word operator (length, plus, …) now fails with a clear message instead of silently creating a variable you could never read back. To use one of the operator names as a variable, switch the word operators off with the no-written-operators pragma.

length = 10
-- error: cannot assign to 'length': it is a written-word operator

{--! no-written-operators !--}
length = 10                  -- now fine: 'length' is an ordinary variable

Pragmas

File-level directives. A pragma is a magic comment, usually placed at the top of a file, that opts that file into a stricter or looser behaviour. Several can be combined, and they only affect the file they appear in.

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

x = "count: " + 5    -- error: strict-types forbids the implicit coercion
x = 10
x = 11               -- error: immutable forbids reassignment
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

Language additions

Written-word operators. Every symbol operator now has a plain-English spelling, so you can write code that reads aloud like a sentence: plus, minus, times, divided-by, is-greater-or-equal, and, or, not, concat, and-do (pipe), be (assignment), and more. Symbols and words are interchangeable and can be mixed freely.

3 is-greater 2 and 5 is-less-or-equal 5    -- true
[1, 2] concat [3, 4]                       -- [1, 2, 3, 4]
total be 10 plus 5                         -- assignment: total is now 15

The )| pipe operator. The pipe threads a value into a function as its first argument, so a chain of transformations reads from left to right in the order it happens, rather than nested inside-out.

[1, 2, 3, 4] )| filter(\x -> x % 2 == 0)   -- [2, 4]

Readable function syntax. Function and lambda bodies can be introduced with the words returns and such-that in place of the -> arrow, and a lambda can be written with the lambda keyword. The symbolic forms still work everywhere.

def double(n) returns n * 2                -- 'returns' reads better than ->
double(21)                                 -- 42
map([1, 2, 3], lambda x such-that x * 10)  -- [10, 20, 30]

Pattern-matching guards. A function clause can carry a when guard. The clause only matches when its patterns line up and its guard expression is true, letting you branch on a value rather than only on its shape.

def grade(n) when n is-greater 90 -> "A"
def grade(n) when n is-greater 70 -> "B"
def grade(n) -> "C"
grade(95)                                  -- "A"

The for-in keyword. Loops accept the word in alongside the : separator, so iterating over a collection reads like ordinary English.

loop char in "Bern" do
    char
end

The loop keyword. loop is now the preferred spelling for every loop form - repeat, conditional, and iteration. The original for keyword still works everywhere as a backwards-compatible alias.

loop 3 do "hi" end           -- repeat
loop n : [1, 2, 3] do n end   -- iterate (for ... still works too)

List comprehensions. A list can be built from one or more generators (pattern <- source) and optional boolean guards, written as [expression | qualifiers]. Generators nest like loops to produce every combination, a generator's pattern destructures as it iterates (elements that don't match are skipped), and guards filter the result.

[x * x | x <- [1..5]]                 -- [1, 4, 9, 16, 25]
[x | x <- [1..10], x % 2 == 0]      -- [2, 4, 6, 8, 10]
[a + b | a <- [1, 2], b <- [10, 20]]  -- [11, 21, 12, 22]

Cons expressions. The [head | tail] syntax now works as an expression too, prepending one or more elements onto an existing list (and {head | tail} onto a set). Prepending is lazy, so you can cons onto a huge range and still get an instant result.

[0 | [1, 2, 3]]              -- [0, 1, 2, 3]
[1, 2 | [3, 4]]             -- [1, 2, 3, 4]  (multiple heads)
big = [0 | [1..1000000000]]    -- instant; :> big is 1000000001

Standard library & builtins

JSON parsing and serialising. The new json library reads and writes JSON text. json_parse turns a JSON string into Bern values you can index into, and json_stringify serialises them back out.

import json

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

Set-based JSON arrays. JSON arrays map onto Bern's set-based model in a way that preserves the multiplicity of duplicate elements, grouping repeated values together instead of discarding them.

import json

json_parse("[1, \"a\", 34.2, \"a\"]")
-- {1, ["a", "a"], 34.2}    (duplicates grouped, multiplicity preserved)

keys(object). The keys builtin returns the list of keys held by an object, which makes it easy to walk a hashmap whose shape you do not know ahead of time.

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

error and is_error. Two builtins back the new error-value model: error builds an error value that carries a message, and is_error reports whether a given value is one.

boom = error("something went wrong")
is_error(boom)              -- true
is_error(42)                -- false

Tooling

Improved REPL. The interactive REPL gained persistent history across sessions, multi-line blocks, the last result bound to _, and the meta-commands :help, :load, :reset and :quit. Pragmas can be enabled from the prompt with :pragma <name> (and listed with :pragmas), in addition to inline {--! … !--} comments. A runtime error now prints and returns you to the prompt instead of ending the session.

> x = 21
> x * 2
42
> _ + 8         -- _ is the previous result
50
> :help         -- list the meta-commands

Standalone executables. bern build bundles a script together with every library it imports into a single standalone executable that runs without a separate Bern installation.

bern build app.brn -o myprogram.exe
./myprogram.exe        -- runs app.brn with its bundled libraries

Bern 1.1.3 February 2026

The final 1.x line focused on richer pattern matching, a cleaner module system, and editor tooling.

Language additions

Conditionals as expressions. An if/then/else is now an expression that evaluates to a value, and it can be chained into else-if ladders. Because it returns a value, it can appear anywhere an expression is expected, including directly as a function body.

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

The case ... is expression. This expression matches a value against a series of patterns inline and returns the matching branch. It can match on constructors and bind their fields, and with ::v it can match on the value's type name as a string.

adt Shape = Circle Double | Square Double

def name(v) -> case v is Circle(_) = "circle" | Square(_) = "square" | _ = "?" end
name(Circle(5.0))            -- "circle"

def kind(v) -> case ::v is "Circle" = "a circle" | _ = "something else" end

Iterative ADTs. An ADT declared with the iterative keyword can be looped over directly, yielding the fields it was constructed with.

adt iterative Shape = Circle Double | Rectangle Double Double

vals = []
for p : Circle(5.0) do
    vals = vals <> [p]
end
vals                         -- [5.0]

Modules

Import aliases and qualified access. An import can be given a short alias with as, and any imported name can be reached through its qualifier using the module:function form.

import liba as la
import libb as lb

la:map([1, 2], \z -> z + 10)   -- uses liba's map
lb:map([1, 2], \z -> z + 10)   -- uses libb's map

Import-conflict resolution. When two libraries export the same name, that name is no longer ambiguous. Each definition stays reachable through its own qualifier, so the conflicting libraries can coexist in one file.

import liba
import libb

liba:map([1, 2], \z -> z + 10)   -- liba's definition
libb:map([1, 2], \z -> z + 10)   -- libb's definition

The bernparsec library. A parser-combinator library inspired by Megaparsec joined the vendored libraries. It builds parsers from small, composable pieces such as satisfy, pure and fail_parser.

import bernparsec

-- build parsers from small, composable pieces
digit = satisfy(\c -> c >= '0' && c <= '9')

Tooling

Syntax highlighting and IntelliSense. The editor extension gained full syntax highlighting, with keyword and type colouring, plus IntelliSense completion as you type.

Nix flake. A Nix flake was added for reproducible builds and development environments, contributed by ProggerX.

nix build              # build Bern from the flake
nix run . -- app.brn   # run a script with that build

Bern 1.1 January 2026

The 1.1 series brought a working C foreign function interface and a much better error-reporting experience.

FFI

Universal C FFI. Bern gained a real foreign function interface to C that works the same way on Windows and Linux, replacing the earlier placeholder stubs. A foreign declaration names the symbol, the library to load it from, its argument types and its return type, after which it is called like any Bern function.

foreign rand("msvcrt.dll") -> "int"
foreign srand("msvcrt.dll", "int") -> "void"

srand(42)
rand()                 -- a C-generated random integer

ADT-aware FFI and Raylib bindings. Foreign calls can now read and return algebraic data types, not just primitive numbers. That made it possible to ship a near-complete set of Raylib bindings for graphics and input.

import raylib

-- ADTs cross the FFI boundary, so colours, vectors and
-- rectangles can be passed straight to the C bindings
InitWindow(800, 450, "Bern + Raylib")

Language & runtime

Global variable assignment. The := operator assigns into the global scope from anywhere, including inside a function body, so a function can update shared state rather than only its local bindings.

counter := 0           -- create or update a global binding
def tick() -> counter := counter + 1
tick()
counter                -- 1

Better tracebacks. Runtime errors now print a traceback that shows the chain of calls leading to the failure, with a file and line for each frame, making the source of a problem far easier to find.

oops()
-- Traceback (most recent call last):
--   in oops   (app.brn:3)
--   in main   (app.brn:7)
-- Error: division by zero

NaN in assignments. Expressions that evaluate to NaN, such as dividing zero by zero, can now be assigned and carried through later computation instead of stopping evaluation.

x = 0 / 0              -- NaN, assigned without halting evaluation

Standard library

CSV reading library. A CSV library joined the vendored libraries. read_csv loads a file and returns its rows, with optional control over the delimiter, whether the first line is a header, and whether empty lines are skipped.

import csv

rows = read_csv("data.csv")              -- comma-delimited, first row is a header
read_csv("data.tsv", "\t", false, true)  -- custom delimiter, no header

random library. A random library wraps the C standard library through the new FFI to provide seeded random numbers, including helpers like get_random_int and random_choice.

import random

get_random_int()                              -- a random integer
random_choice(['apple', 'banana', 'cherry'])  -- one element at random

get_host_machine(). A new global function reports the host platform, which libraries use to pick the right native library to load at runtime.

get_host_machine()     -- e.g. "mingw64" on Windows, used to choose a C library

Bern 1.0 January 2026

The first public Bern: an interpreted, dynamically typed, expression-oriented language with pattern-matching functions.

Core language

Lists and sets. Bern shipped with first-class lists and sets, including set-theory operators for union, intersection and difference, and concatenation for lists.

[1, 2, 3] <> [4, 5]      -- [1, 2, 3, 4, 5] (concatenation)
{1, 2, 3} |> {2, 3, 4}   -- {2, 3} (intersection)
{1, 2, 3} </> {2, 3, 4}  -- {1} (difference)

The for-in loop. A single for keyword covered iteration, using the : separator to walk the elements of a collection.

for n : [1, 2, 3] do
    n * 10
end

Conditionals. Branching used if/then/else blocks closed with end, with else-if chains for further branches.

if age >= 18 then
    "adult"
else
    "minor"
end

Object notation. Objects, which double as hashmaps, were written with the #{ ... }# notation and indexed by key.

obj = #{ name: "Bern", version: 1 }#
obj["name"]              -- "Bern"

Algebraic data types. ADTs let you define a type with several constructors and then pattern-match on those constructors directly in function clauses.

adt Shape = Circle Double | Rectangle Double Double
def area(Circle(r)) -> 3.14159 * r * r
def area(Rectangle(w, h)) -> w * h
area(Circle(5.0))        -- 78.53975

List-of-characters strings. The dedicated Text type was removed in favour of representing strings as lists of characters, so every list operation works on text and a string indexes to a character.

"Bern"[0]                -- 'B'
['H', 'i']               -- "Hi"

Modulo. The modulo operator % returns the remainder of a division.

17 % 5                   -- 2

Standard library & I/O

The core library. The core library provided the staple higher-order functions such as map and filter.

import core

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

The strings library. A dedicated string library collected text helpers, including character-case conversions.

import strings

char_to_upper('a')       -- 'A'

File reading and writing. File input and output arrived through read_file and write_file.

write_file("hello.txt", "Hi there")
read_file("hello.txt")               -- "Hi there"

User input. The input function reads a line of text typed by the user, optionally showing a prompt first.

name = input("Type your name: ")

Conversions. Helper functions turned characters and strings into integers.

to_int("42")             -- 42

Imports. Libraries, whether built in or written by you, were brought in with the import keyword.

import core            -- a built-in library
import tutorials/adt   -- a user-written module, by path

Tooling

Interactive REPL. A read-eval-print loop let you evaluate expressions one at a time.

> 1 + 1
2

Documentation website. The documentation site launched, published automatically with GitHub Pages, alongside one-line install scripts for PowerShell and Bash.

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