The Bjolang manual

Getting started

Bjolang is only installed from source. There are no releases or packages: you clone the repository at github.com/bjoli/Bjolang and build it where it is.

It needs only two things: dotnet 10 and an f# compiler. The .NET 10 SDK comes with both. Once that is installed, clone the repository and build the runtime, the compiler and the standard library:

git clone https://github.com/bjoli/Bjolang
cd Bjolang
dotnet build BjolangRuntime/BjolangRuntime.csproj -c Release
dotnet build -c Release
./build_std.sh

If you want to make sure everything works, run ./run_tests.py and ./run_bjo_tests.py in the same directory. Then you can use the bjo executable in the bjo directory: ./bjo/bjo run hello.bjo. Personally I alias a shell command to the path of bjo, since there currently is no way to install it properly. Compiled programs find the standard library in the directory you built it in, so leave the checkout where it is.

To update, pull and build again:

git pull
./bjo/bjo rebjo
./build_std.sh

Your first bjolang program

Bjolang compiles to c#. Many of the design decisions behind bjolang stems from this limitation. The first one you will encounter is that a program needs a main function. like the one in c#, that returns an int. If execution ended correctly it should return 0 if the program executed successfully.

(: print-hello (-> string unit))
(defun (print-hello name)
  (println (str "Hello, " name "!")))

(defun (main args)
  (print-hello "User")
  0)

This can then be run using bjo run filename, and it should print Hello, User!.

What you can see here is that defun is the form for defining a function. main is special, it needs no type declaration. print-hello is a regular function, and thus has to declare its type. Everything at toplevel needs to do that. The form is (: name type). A function type is declared as (-> ArgType … Return-Type). print-hello takes a string argument and returns unit. unit is the name for nothing.

Declaring types

For most of your bjolang code, you will only declare types in the top level. Types within functions are inferred, so declaring types there is almost never necessary. A value type is declared just like a function, but without the function type declaration:

(: usermap (Map Keyword string))
(def usermap #map((:admin "Linus") (:moderator "Sunil") (:janitor "Björnstam")))

You can also declare your own types. Either aliases of other types, records of your own, or union types.

(type
  (: Suit (Union Hearts Diamonds Clubs Spades))
  (: Card (Struct (: rank byte) (: suit Suit)))
  (: User (Record (: name string)
                  (: age byte)
                  (: favourite-playing-card Card)))))

This can then be destructed using pattern matching.

(: print-user (-> User unit))
(defun (print-user u)
  (match u
    ;; Subpatterns: here we match name, age, the Card rank
    ;; but only succeed if the Suit is Hearts.
    ((User name age (Card rank (Suit Hearts)))
      (println "Oh my god, we got hearts!"))
    ((User (age 100))
      (println "I have no idea which card, but the user is old"))
    ((User name) (println "The user is called ${name}."))
    ;; pattern matching does it's best to do exhaustiveness checks.
    ;; and while it is usually pretty good at figuring out whether
    ;; patterns are exhaustive, sometimes it cannot. Then you will have to add
    ;; a match-all
    (_ (panic! "This should not happen."))))

Editor support

Bjolang was written in emacs, and emacs is currently the only editor with limited bjolang support through the file bjomode.el in the main github repository. It lacks any fancy things, but handles a modicum of indentation and syntax highlighting.

Values, types and signatures

These are the bjolang literals:

TypeForm
int,byte,long, ulong uint1 0 -4 200000
long-5000000000L
ulong50000000000UL
double1.234
string“hej”
symbol‘hej
keyword#:keyword :keyword (these are equal)
character#\space #\a #\newline
bool#t #f
unit/voidunit

Then there are some special cases, like string interpolation:

(let ((an-int 5)
      (a-string "hej"))
  (println $"This is interpolated: ${an-int} ${a-string}")
  ;; Which is literally the same as:
  (println (str "This is interpolated: " (->str an-int) " " (->str a-string))))

Function types

A function takes any number of arguments and produces a value. The signature of a function in bjolang is denoted using the arrow ->:


(: double (-> int int))
(defun (double x)
  ;; the last expression in the body is the return value
  (* x 2))

(:print-n-times (-> string int unit))
(defun (print-n-times what n)
  (loop (:for i (up-from 0 #:to n))
    (println what)))

;; call twice takes a function take takes an argument
;; of type %a and returns unit. 
(: call-twice (-> (-> %a unit) %a unit))
(defun (call-twice f val)
  (f val)
  (f val))

Bjolang also supports keyword arguments. The caller do not have to supply them, and if they are not supplied they are bound to a default expression that is evaluated in the function body.

(: n-times (-> int (#:what string) string))
(defun (n-times n #:what (string-append "place" "holder"))
  (def sb (stringbuilder-empty))
  (loop (:for i (up-from 0 #:to n))
    (stringbuilder-add-string! sb placeholder))
  (stringbuilder->string sb))

;; Calling it now returns:
(n-times 2) ;; -> "placeholderplaceholder"
(n-times 500 #:what "hej!\n") ;; -> "hej!\nhej!\nhej!\n..."

The default expression of the keyword argument is evaluated in the body of the function every time the function is called without a supplied argument. Keywords must also be in the same order in the signature and definition. A function with keywords yields two c# bodies, one with optional arguments and one with the default expressions, UNLESS the default arguments are c# literals. Then the function is a regular c# function with named arguments.

Inference

Everything at the toplevel needs a signature, even values. In a function body, nothing strictly needs a signature. Function bodies are inferred using Hindley Milner type inference. This is of course comfortable, but also has some limits. They type of an undeclared type is inferred at first usage.

;; pretend we are in a function body
(def a (transientmap-empty))
(add! a (:key . 5)) ;; transientmap is pinned to (Transientmap Keyword int).
(add! a (:key2 . "hej")) ;; fails

The example above is of course facile, but it matters more for generic functions with inlined traits. They are pinned to the first use, and any subsequent usage will raise an error. More on this in the trait section.

Naming conventions

Bjolang follows scheme’s naming conventiens. Predicates end with ?. Mutating functions end with !. Conversions are written using ->.

While the base types are lowercase (which is a design mishap), types should be capitalized. Record and struct fields are lower case.

Functions

As you have seen already, functions are declared using defun. Bjolang also has anonymous functions defined using fun:

(list-map (fun (x) (* x x)) (list 1 2 3 4)) ;; -> '(1 4 9 16).
;; there is also a lambda shorthand where argument are numbered
;; using &. & = &1.

(println  (#(+ & &2) 2 3)) ;; -> prints 5.
(println (#(+ &1 &3) 1 2 3)) ;; -> prints 4.

Functions can also be generic, to handle any kind of argument. This is useful if you have a function that does not need to inspect the values.

(: make-tuple (-> (List %a) %b (Tuple %a %b)))
(defun (make-tuple ls val)
  (match ls
    ((Cons car _) (Tuple car val))
    (_ (panic! "this did not work as expected."))))

Generics can also be constrained to work on Traits. For example this signature, for a function that can compare the first element of a tuple with the elements of a Vec.

(: (-> (Tuple %a %b) (Vec %a) bool) (where (Eq %a)))

Local functions and inline signatures

A local function defined inside another function looks like a top-level defun, but does not need a separate (: ...) type signature because local types are inferred automatically with Hindley-Milner type inference. In fact, local functions do not support separate (: ...) signatures at all.

When you want to document types explicitly or constrain inference, you can write the signature inline directly on the defun by annotating each parameter with (: param Type) and following the parameter list with : ReturnType:

(defun (process-numbers ls)
  (defun (clamp (: n int) (: max int)) : int
    (if (> n max) max n))
  (list-map #(clamp & 100) ls))

Parameters can use any type, including type variables for generic local functions:

(defun (pair-up val ls)
  (defun (wrap (: x %a)) : (Tuple %a %b)
    (Tuple x val))
  (list-map wrap ls))

Note that if you supply an inline signature, all parameters and the return type must be annotated together; partial inline annotations are not permitted. Trait constraints (where clauses) and keyword argument defaults cannot be written inline and require top-level signatures.

Bindings and control flow

binding forms

Variables are bound using, primarily, four forms def, def* let, let*. let and let* are the same as in scheme. let binds values in parallel. let* binds sequentially.


(def a 1)
(def b 2)

(let ((a 4) (b (+ a 1)))
  (println a) ;; -> 4
  (println b)) :: -> 5

(let* ((a 4) (b (+ a 1)))
  (println a) ;; 4
  (println b)) ;; 5

def* is a form that mostly just saves on key presses:

(def* (a 5)
      (b 6)
      (c (+ a b)))

conditional executions - if, cond, when, unless, and case

The base form for executing code conditionally is (if test-expr true-expr false-expr). The prelude defines the cond macro that allows for chaining tests:


(def weather (get-weather-symbol))
(if (= weather 'rain)
    (get-umbrella)
    (if (= weather 'windy)
        (get-jacket)
        (get-shorts)))
;; can be written as
(cond
  ((= weather 'rain) (get-umbrella))
  ((= weather 'windy) (get-jacket))
  (else (get-shorts)))

;; which can be written as

(case weather
  ('rain (get-umbrella))
  ('windy (get-jacket))
  (else (get-shorts)))

For the cases where code starts drifting rightward, there are two special clauses in the cond macro, :def and :do. This is taken from actual bjolang code.

(cond
  ((> depth json-max-depth) (Err (too-deep rd "array")))
  ;; this executes two things and binds the variable `mark` before continuing
  (:do
    (step! rd)
    (skip-space! rd))
  (:def mark (record-ref rd top))
  ((at-end? rd) (Err (at rd "the document ended inside an array")))
  ((char=? (peek rd) #\]) (step! rd) (Ok (JsonArr (harvest! rd mark))))
  (else ...))

The above is usually a code smell, but can be useful in managing the kind code drift that sometimes plagues lisp code.

with-return and bailing from failed bindings

Bjolang has 2 solutions to the problem that is sometimes solved by do-notation in functional languages. Thi (with-return ret body ... form binds an espace function to ret, which means code within it can return from the body (but not within local functions). This is often used with the destructuring of the def and def* forms.

Within a with-return you can use the bound label to directly return a value from the with-return block

(with-return ret
  (def a (read-line))
  (when (= "" a)
    (ret 0))
  (println "hej hopp hej hej")
  (string-count a))

The def forms have a particular thing that they do when they are usid with refutable patterns (patterns that can fail). Then they take one of four extra clauses:


(: do-things (-> (List int) (Result string string)))
(defun (do-things ls)
  ;; Try to match a list with 3 elements and return an Err if it fails
  (def (List a b c) ls :leave-with (Err "The string did not have 3 elements"))
  ;; Try get the key "a" from a map and leave with err
  (def (Some elem) (try-ref my-map a) :leave-with (Err "The map lacked a key"))
  ;; open the filename in `elem` and propagate any error. 
  (def (Ok port) (open-input-file elem) :propagate)
  (println "Here we know port is bound. ")
  (Ok "the file exists"))

The other clauses are :default expr that binds the variable to expr if the match fails. This only works with patterns that bind one variable. Then there is :leave which take the other pattern arms:

(def (Ok b) expr :leave ((Err "ojdå") (println "special swedish error encountered") expr)
                        ((Err b) expr))

Mutable bindings

Mutable bindings are defined with def/mutable. They can be mutated with the set! form. Generic local mutable variables generalize at the first set!, and top-level generic bindings are not allowed.

(defun (main args)
  (def/mutable b 0)
  (loop (:for a args)
        (:when (my-special-predicate? a))
        (set! b (+ b a)))
    b)

Your own types

A program without your own types are boring.

Aliases

A type alias is a different name for a type that already exists. It is declared with the type form.

(type (: Stringalias string))

Records and structs

Records and structs are direct mappings to c# records and immutable structs. The constructor is the type name, together with parenthesised fields. As follows.

(type
  (: Ripeness (Union Ripe Megaripe Rotten))
  (: Fruit (Record (: name string)
                   (: amount int)
                   (: ripeness Ripeness))))

(def banana (Fruit (name "banana") (amount 5) (ripeness Rotten)))

A field can be marked #:mutable, to make the record (not struct) mutable. This however makes using the record in places that requires hashing fail at runtime. The mutable fields can be updated with record-set!.

(type (: Reader (: pos integer #:mutable)
                (: port TextInputPort)))

...
(record-set! my-reader pos (+ 1 (record-get my-reader pos)))

Unions

A union is a type whose values are one of several cases. A case either carries nothing, and is then written as a bare name, or it carries a payload, and is then written like a record field: (: CaseName type ...).

(type
  ;; Four cases that carry nothing.
  (: Suit (Union Clubs Diamonds Hearts Spades))
  ;; A card's rank is either a number card, carrying its number, or a court card.
  (: Rank (Union (: Pip int) Jack Queen King Ace))
  ;; A case may carry more than one value.
  (: Shape (Union (: Circle double) (: Rect double double) Empty)))

A case is also its constructor. A case without a payload is a value, and a case with a payload is a function that builds one:

(def trump Hearts)                 ;; Suit
(def seven (Pip 7))                ;; Rank
(def square (Rect 2.0 2.0))        ;; Shape

;; Since (Pip ...) is a function, it can be passed around like one:
(list-map Pip (list 2 3 4))        ;; a (List Rank) of three number cards

You take a union apart with match, which gets its own section below. The short version is that every case gets a clause, and the payload is bound to names:

(: area (-> Shape double))
(defun (area s)
  (match s
    ((Circle r) (* 3.14159 (* r r)))
    ((Rect w h) (* w h))
    (Empty 0.0)))

(area (Rect 2.0 3.0))   ;; 6.0

The compiler checks that a match covers every case. If you add a Triangle to Shape, every match on a shape that does not handle it becomes a compile error, which is most of the reason to use a union in the first place.

Unions can be generic. The type variables go after the type name, just like for records:

(type (: (Maybe %a) (Union Nothing (: Just %a))))
(type (: (Either %l %r) (Union (: Left %l) (: Right %r))))

(: maybe-first (-> (List %a) (Maybe %a)))
(defun (maybe-first xs)
  (match xs
    (() Nothing)
    ((Cons x _) (Just x))))

You never have to write these two, since the prelude already has them under the names (Option %a), with the cases None and Some, and (Result %e %a), with the cases Err and Ok. They are ordinary unions, and they get a section of their own further down.

A few things to know about unions:

Types that refer to themselves and each other

A type may mention itself. This is how you write trees, expression languages and anything else recursive:

(type (: (Tree %a) (Union Leaf (: Node (Tree %a) %a (Tree %a)))))

(: tree-insert (-> int (Tree int) (Tree int)))
(defun (tree-insert x t)
  (match t
    (Leaf (Node Leaf x Leaf))
    ((Node l v r)
     (cond ((< x v) (Node (tree-insert x l) v r))
           ((> x v) (Node l v (tree-insert x r)))
           (else t)))))

(: tree->list (-> (Tree %a) (List %a)))
(defun (tree->list t)
  (match t
    (Leaf '())
    ((Node l v r) (list-append (tree->list l) (Cons v (tree->list r))))))

(tree->list (list-foldl tree-insert Leaf (list 5 2 8 1 9 2)))  ;; '(1 2 5 8 9)

Types may also refer to each other. All the types of a module are known before any of them is checked, so the order they are declared in does not matter, and they do not even have to be in the same type form:

(type (: Expr (Union (: Num int)
                     (: Add Expr Expr)
                     (: Let Binding Expr)
                     (: Var string))))

(type (: Binding (Record (: name string) (: value Expr))))

Deriving equality and ordering

Every type can be compared with =. By default a record, struct or union is compared field by field. To use your type as a key in a hash map, or to sort it, it is better to say so explicitly with type/derive. It is written like type, with a list of traits in front:

(type/derive (Eq Ord)
  (: Suit (Union Clubs Diamonds Hearts Spades))
  (: Rank (Union (: Pip int) Jack Queen King Ace))
  (: Card (Struct (: rank Rank) (: suit Suit))))

Eq gives you = and a matching hash. Ord gives you an ordering, and with it compare, less?, list-sort, list-max and everything else that orders things. The derived order is the obvious one:

(list-sort (list (Card (rank Ace) (suit Spades))
                 (Card (rank (Pip 7)) (suit Hearts))
                 (Card (rank (Pip 10)) (suit Clubs))
                 (Card (rank (Pip 7)) (suit Clubs))))
;; 7♣ 7♥ 10♣ A♠

(less? (Card (rank (Pip 2)) (suit Spades))
       (Card (rank Jack) (suit Clubs)))     ;; #t: rank decides first

Note that < and > are not in that list. They are for numbers and characters only. For anything with an Ord, use less?, greater?, at-most? and at-least?, or compare, which returns a negative number, zero or a positive number.

When the derived order is not the one you want (for example if aces should sometimes be low), you write the implementation by hand. That is covered in the traits section.

The card game

The rest of this chapter uses a card game as a running example, so here are its types in one place. Put this at the top of a file and the examples in the following sections can be pasted below it.

(type/derive (Eq Ord)
  (: Suit (Union Clubs Diamonds Hearts Spades))
  (: Rank (Union (: Pip int) Jack Queen King Ace))
  (: Card (Struct (: rank Rank) (: suit Suit))))

(type (: Player (Record (: name string)
                        (: hand (Vec Card))
                        (: tricks int))))

;; How a card prints. Traits are explained later; for now it is enough
;; to know that this is what println and #"...${...}" use.
(impl (->str Suit)
  (defun (->str s)
    (match s (Clubs "♣") (Diamonds "♦") (Hearts "♥") (Spades "♠"))))

(impl (->str Rank)
  (defun (->str r)
    (match r
      ((Pip n) (int->string n))
      (Jack "J") (Queen "Q") (King "K") (Ace "A"))))

(impl (->str Card)
  (defun (->str c) #"${(record-ref c rank)}${(record-ref c suit)}"))

(println (Card (rank Queen) (suit Hearts)))   ;; Q♥

Card is a struct because it is two small values and is copied around a lot. Player is a record because it holds a whole hand. That is the usual rule of thumb: a struct for something small that behaves like a value, a record for everything else. A struct also cannot contain itself and cannot have mutable fields.

Records are updated by copying, with record-set:

(: won-trick (-> Player Player))
(defun (won-trick p)
  (record-set p (tricks (+ 1 (record-ref p tricks)))))

(def ada (Player (name "ada") (hand []) (tricks 0)))
(record-ref (won-trick (won-trick ada)) tricks)   ;; 2
(record-ref ada tricks)                           ;; still 0

Pattern matching

Pattern matching is how you take values apart in bjolang. There is no car/cdr style of programming on unions: you say what shape you expect and the compiler binds the parts for you, and checks that you did not forget a shape.

match

(match value clause ...) tries each clause in turn. A clause is a pattern followed by a body, and the first clause whose pattern fits is the one that runs. Its last expression is the value of the whole match.

The basic patterns are:

PatternMatches
42 "hi" #\a #texactly that literal
:red 'symexactly that keyword or symbol
_anything, and binds nothing
nameanything, and binds it to name
Heartsthe case Hearts (capitalised: a constructor)
(Pip n)the case Pip, binding its payload to n

The difference between a binder and a constructor is the first letter. A lowercase name binds, a capitalised name is a case. This is also the source of one classic mistake: if you misspell a case, or forget to import it, it becomes a binder that matches everything. The compiler will usually tell you, because the clauses below it can then never run.

(: light (-> Keyword string))
(defun (light k)
  (match k
    (:red "stop")
    (#:green "go")          ;; :green and #:green are the same keyword
    (_ "wait")))

(: rank-points (-> Rank int))
(defun (rank-points r)
  (match r
    ((Pip n) n)
    (Ace 11)
    (_ 10)))

Patterns nest. A payload can itself be matched against a pattern:

(match (Some (Pip 10))
  ((Some (Pip 10)) "a ten")
  ((Some (Pip n)) #"the number ${n}")
  ((Some court) "a court card or an ace")
  (None "no card"))

Records and structs

A record or struct is matched by its type name followed by the fields you care about, each written (field pattern). Fields you leave out match anything, and the order does not matter. A field name on its own binds the field to a variable of the same name, which is by far the most common way to use it:

(: card-name (-> Card string))
(defun (card-name c)
  (match c
    ((Card (rank Ace) (suit Spades)) "the ace of spades")
    ((Card (rank (Pip 10))) "a ten")
    ;; `suit` alone binds the suit field to the name suit
    ((Card (rank (Pip n)) suit) #:when (< n 5) #"a low card (${n}${suit})")
    ((Card (rank (or Jack Queen King))) "a court card")
    ((Card rank) #"something else: ${rank}")))

(: player-line (-> Player string))
(defun (player-line p)
  (match p
    ((Player name (tricks 0)) #"${name} has not won anything")
    ((Player name tricks) #"${name} has won ${tricks} tricks")))

A bare type name, like Card, is a pattern that matches every card.

Lists, vecs, arrays and tuples

Collections have patterns too. ... after the last name binds the rest of the collection:

PatternMatches
() or (List)the empty list
(List a b)a list of exactly two elements
(List first rest ...)a list of at least one element
(Cons head tail)a non-empty list, split in head and tail
[] [a b] [a rest ...]the same for a Vec
#[] #[a rest ...]the same for an Array
(a . b) or (Tuple a b)a pair
(: describe (-> (List int) string))
(defun (describe xs)
  (match xs
    (() "empty")
    ((List x) #"one: ${x}")
    ((List 0 rest ...) "starts with zero")
    ((List x y rest ...) #"${x}, ${y} and ${(list-length rest)} more")))

(describe (list 1 2 3 4))   ;; "1, 2 and 2 more"

(: header (-> (Vec string) string))
(defun (header v)
  (match v
    ([] "no columns")
    ([only] only)
    ([first rest ...] #"${first} (+${(vec-length rest)})")))

(: pair (-> (Tuple int string) string))
(defun (pair p)
  (match p
    ((0 . s) #"zero and ${s}")
    ((_ . "x") "an x")
    ((n . s) #"${n}/${s}")))

The rest of a vec pattern shares its structure with the original vec, so it is cheap. The rest of an array pattern is a copy.

Guards, or and and

A clause may have a guard, written #:when expr after the pattern. The clause only matches if the pattern fits and the guard is true, and the guard can use everything the pattern bound:

(match hand-size
  (0 "no cards")
  (n #:when (> n 10) "too many cards")
  (n #:when (even? n) "an even hand")
  (_ "an odd hand"))

(or p ...) matches if any of the alternatives match. The alternatives may not bind names, since there would be no way to know which of them did. (and p ...) matches if all of them match, which is mostly useful to bind a name to the whole value while also taking it apart:

(: red? (-> Suit bool))
(defun (red? s)
  (match s
    ((or Hearts Diamonds) #t)
    (_ #f)))

(match (Some 3)
  ((and (Some n) whole) #"${n} inside ${whole}")   ;; "3 inside (Some 3)"
  (None "none"))

Views, type tests and Has

(:view f pattern) calls f on the value and matches the result against pattern. That lets a match look at something that is not directly in the value, like whether a string parses as a number:

(: parse-int (-> string (Option int)))
(defun (parse-int s)
  (match (try (string->int s) #:catch (System.FormatException))
    ((Ok n) (Some n))
    ((Err _) None)))

(: classify (-> string string))
(defun (classify s)
  (match s
    ((:view parse-int (Some n)) #:when (even? n) "an even number")
    ((:view parse-int (Some n)) "an odd number")
    ((:view string-length 0) "nothing")
    (_ "text")))

(:is Type name) tests the .NET type of a value, and binds it with that type. It is mostly used to tell exceptions apart, and there is an example of that in the section on failure.

(Has key pattern) matches any collection you can look things up in (maps, vecs) that has key, and matches the value under it against pattern. Has* takes several key and pattern pairs:

(: port-of (-> (Map string int) int))
(defun (port-of settings)
  (match settings
    ((Has "port" p) p)
    (_ 8080)))

(: user-line (-> (Map string string) string))
(defun (user-line m)
  (match m
    ((Has* ("name" n) ("city" c)) #"${n} from ${c}")
    ((Has "name" n) n)
    (_ "anonymous")))

Exhaustiveness

Every match is checked, and a match that does not cover every value is a compile error. The error names a value that falls through:

(: red? (-> Suit bool))
(defun (red? s)
  (match s
    (Hearts #t)
    (Diamonds #t)
    (Clubs #f)))
Pattern Error at cards.bjo:8: this match does not cover every value. Spades reaches no clause.

The counterexample is a real value, and for nested patterns it tells you exactly which combination is missing:

(match c
  ((Card (rank (Pip n))) n)
  ((Card (rank (or Jack Queen King))) 10)
  ((Card (rank Ace) (suit Spades)) 50))
Pattern Error at cards.bjo:8: this match does not cover every value. (Card (rank Ace) (suit Clubs)) reaches no clause.

A clause that can never run, because the clauses above it already match everything it would, is also an error:

Pattern Error at cards.bjo:5: this clause can never run — the clauses above it already match everything it does.

The checker does not run your code, so a clause with a #:when guard, a :view or an :is covers nothing as far as it is concerned. A match that only has guarded clauses needs a final catch-all:

(match n
  (x #:when (< x 10) "small")
  (_ "large"))          ;; without this line:"_ reaches no clause"

case

case is a short form for the common match against literals. Each clause is a list of values, and else is the catch-all:

(: nth-name (-> int string))
(defun (nth-name n)
  (case n
    ((1) "first")
    ((2) "second")
    ((3) "third")
    (else #"${n}th")))

(case command
  (("quit" "exit") (stop))
  (("help") (show-help))
  (else (run command)))

The data in a clause must be literals: numbers, strings, characters, booleans, keywords or quoted symbols. A union case like Hearts is not allowed, since case could not tell it from a binder. For those, use match with or.

Patterns in def

Any pattern can also be used in def. You have already seen that in the section on with-return, where a pattern that may fail gets a failure part. A pattern that cannot fail needs none, and is simply a way to take something apart without indenting the rest of the function:

(def (Card rank suit) c)              ;; binds rank and suit
(def (who . points) (Tuple "ada" 3))  ;; binds who and points

A pattern that may fail needs one of the failure parts: :leave-with, :leave, :propagate or :default. They are all covered in the section on bailing out of failed bindings. Here is one of each of the two most common:

(: score-line (-> (Map string int) string string))
(defun (score-line scores who)
  ;; If the lookup fails, the function's value is the :leave-with value.
  (def (Some n) (map-try-ref scores who) :leave-with #"${who} has not played")
  #"${who}: ${n}")

(: total (-> (Map string int) string string (Option int)))
(defun (total scores a b)
  ;; If either lookup fails, the None is passed on as the function's value.
  (def* ((Some x) (map-try-ref scores a) :propagate)
        ((Some y) (map-try-ref scores b) :propagate))
  (Some (+ x y)))

Example: who wins the trick

In many card games, everyone plays one card, and the highest card in the suit that was led (the suit of the first card) wins. Here a trick is a list of who played what, in order:

(: trick-winner (-> (List (Tuple string Card)) (Option string)))
(defun (trick-winner trick)
  (match trick
    (() None)
    ((Cons (_ . lead) _)
     (def led-suit (record-ref lead suit))
     (def best
       (loop (:for (who . card) trick)
             (:when (= (record-ref card suit) led-suit))
             (:acc top (folding (Tuple "" lead)
                                (match top
                                  ((_ . top-card)
                                   (if (at-least? card top-card)
                                       (Tuple who card)
                                       top)))))
             => top))
     (def (winner . _) best)
     (Some winner))))

(trick-winner (list (Tuple "ada" (Card (rank (Pip 9)) (suit Hearts)))
                    (Tuple "bo"  (Card (rank Ace) (suit Spades)))
                    (Tuple "cy"  (Card (rank Queen) (suit Hearts)))
                    (Tuple "dee" (Card (rank (Pip 10)) (suit Hearts)))))
;; (Some "cy"): bo's ace is higher, but it is not a heart.

The loop is explained in its own section. What matters here is the patterns: (Cons (_ . lead) _) picks out the first card of a non-empty list in one go, (:for (who . card) trick) takes each pair apart as it walks the list, and at-least? works on cards because Card derives Ord.

Options, results and failure

Bjolang has no null. A value that may be missing is an Option, and a computation that may fail returns a Result. Both are ordinary unions from the prelude:

;; (Option %a) is either None or (Some value)
;; (Result %e %a) is either (Err error) or (Ok value)

Exceptions exist too, since everything in .NET throws them, but they are something you turn into a Result at the edge of your program rather than something you use for ordinary control flow.

Option

(: halve (-> int (Option int)))
(defun (halve n)
  (if (even? n) (Some (/ n 2)) None))

(match (halve 7)
  ((Some h) #"half is ${h}")
  (None "7 is odd"))

Matching is always possible, but the prelude has shorter ways to say the common things:

FormDoes
(some? o) (none? o)test which case it is
(option-value o fallback)the value, or fallback for None
(option-ref-or o fallback)the same
(option-ref o)the value, and throws for None
(if-let (name o) then else)then with the value bound to name, or else
(when-let (name o) body ...)the body with the value bound, and nothing for None
(option-value (halve 7) 0)                  ;; 0
(if-let (h (halve 12))
  (println #"half is ${h}")                 ;; prints "half is 6"
  (println "odd"))
(when-let (h (halve 3))
  (println "never printed"))

Result

A Result is an Option that says why it is empty. The error can be any type: a string for a quick script, your own union when the caller needs to act on what went wrong, or a .NET exception.

(: parse-int (-> string (Result string int)))
(defun (parse-int s)
  (match (try (string->int s) #:catch (System.FormatException))
    ((Ok n) (Ok n))
    ((Err e) (Err #"'${s}' is not a number"))))

(: positive (-> int (Result string int)))
(defun (positive n)
  (if (> n 0) (Ok n) (Err #"${n} is not positive")))

(ok? (parse-int "12"))                  ;; #t
(err? (parse-int "twelve"))             ;; #t
(result-map #(* & 2) (parse-int "21"))  ;; (Ok 42)

A Result must be used. In fact every value must be: a form in the middle of a body whose value is dropped is a compile error, unless it is void. For a Result that rule is what keeps a failure from disappearing silently. If you really do not care about a value, say so with (ignore expr):

Type Error at game.bjo:4: this value has type int and is discarded.
  It is a form in the middle of a body, so its value is dropped.
  Every value that is computed and then dropped has to say so: write `(ignore ...)` around it.

Chaining things that may fail

Most real code does several things in a row, each of which may fail, and stops at the first failure. Nesting matches for that drifts to the right very quickly. There are three ways to write it flat.

The first is def with :propagate. When the pattern does not match, the case that did not match is rebuilt and becomes the return value of the function. With an Option that is None, and with a Result it is the same Err:

(: parse-positive (-> string (Result string int)))
(defun (parse-positive s)
  (def (Ok n) (parse-int s) :propagate)
  (def (Ok p) (positive n) :propagate)
  (Ok (* p 10)))

(parse-positive "12")   ;; (Ok 120)
(parse-positive "-4")   ;; (Err "-4 is not positive")
(parse-positive "x")    ;; (Err "'x' is not a number")

The second is try->, a threading form that stops at the first Err. Each step gets what the previous one returned, unwrapped, in the & position. some-> is the same for Option:

(: parse-positive (-> string (Result string int)))
(defun (parse-positive s)
  (try-> s (parse-int &) (positive &)))

(: quarter (-> int (Option int)))
(defun (quarter n)
  (some-> n (halve &) (halve &)))

(quarter 12)   ;; (Some 3)
(quarter 6)    ;; None: 3 is odd

The third is with-return together with :leave-with, which was described earlier. Use it when the failures should turn into something that is not the same type, for example a default value or a log line.

Exceptions: try, :is and raise

.NET code reports failure by throwing. try turns exceptions of the types you list into an Err, and the value of a body that did not throw into an Ok. Anything you did not list keeps propagating as an exception:

(def divisor (string->int "0"))
(try (/ 10 divisor) #:catch (System.DivideByZeroException))
;; (Err <the exception>), of type (Result System.Exception int)

Put the try around the whole region that can fail rather than around each call, and you get a single Result out.

#:finally expr runs expr however the body ends. Without a #:catch nothing is caught, and the value is just the body’s value:

(try (render page)
  #:finally (println "done rendering"))

The Err of a try holds a System.Exception. To tell different exceptions apart, use the :is pattern, which tests the .NET type and binds the value with the more specific type so that you can read its properties. raise throws an exception again, for the cases that are not yours to handle:

(: slurp (-> string string))
(defun (slurp path)
  (match (try (file-read-text path)
           #:catch (System.IO.FileNotFoundException
                    System.IO.DirectoryNotFoundException))
    ((Ok text) text)
    ((Err (:is System.IO.FileNotFoundException e)) #"no such file: ${(.-FileName e)}")
    ((Err (:is System.IO.DirectoryNotFoundException)) "no such directory")
    ((Err e) (raise e))))

with-open

with-open binds something disposable, like a port, a stream or a database connection, runs the body, and disposes it however the body ends:

(: first-line (-> string (Result Exception string)))
(defun (first-line text)
  (try (with-open ((p (open-input-string text)))
         (read-line p))
    #:catch (System.IO.IOException)))

(first-line "first\nsecond")   ;; (Ok "first")

panic!

When something happens that should be impossible, (panic! "message") prints the message and exits the program. It can stand where any type is expected, which makes it useful as the last arm of a match that you know cannot be reached:

(match (map-try-ref deck-index card)
  ((Some i) i)
  (None (panic! "a card that is not in the deck")))

(panic! "config is broken" #:exit-code 2)

Collections

Bjolang’s collections are immutable by default. Adding to a vec or setting a key in a map gives you a new collection and leaves the old one untouched. That sounds expensive, but the new collection shares almost all of its structure with the old one, so an update costs about as much as it would in a mutable collection, and nobody ever has to wonder who else is holding on to the old version.

TypeLiteralWhat it is
(List %a)'(1 2 3) (list 1 2 3)a linked list, cheap at the front
(Vec %a)[1 2 3]an indexable vector (an RRB tree), cheap everywhere
(Array %a)#[1 2 3]a mutable .NET array of fixed length
(Map %k %v)#map(("a" 1) ("b" 2))a hash map (a CHAMP trie)
(Set %a)a hash set, in (std set)
(OrderedMap %k %v)a sorted map (a B-tree), in (std orderedmap)
(OrderedSet %a)a sorted set, in (std orderedset)

If you do not know which to use, use a Vec.

Lists

The list is the scheme list: a chain of Cons cells ending in Nil. It is cheap to add to and take from the front, and slow for everything else. Use it for things you build front to back and walk once.

(def xs (list 1 2 3))
(def ys (Cons 0 xs))       ;; '(0 1 2 3), and xs is still '(1 2 3)

(list-head ys)             ;; 0
(list-tail ys)             ;; '(1 2 3)
(list-length ys)           ;; 4, but it walks the list to find out
(list-map #(* & 10) xs)    ;; '(10 20 30)
(list-filter even? ys)     ;; '(0 2)
(list-foldl + 0 xs)        ;; 6
(list-reverse xs)          ;; '(3 2 1)
(list-append xs xs)        ;; '(1 2 3 1 2 3)

A quoted list is data: '(a b c) is a list of three symbols, not a call. Inside one, ,expr splices in the value of an expression:

(def n 5)
'(1 2 ,n)                  ;; '(1 2 5)

Vecs

Vec is the general-purpose collection. Indexing, updating at an index and adding at the end are all cheap, and it knows its own length.

(def v [10 20 30])
(def v2 (vec-add v 40))     ;; [10 20 30 40]
(def v3 (vec-set v2 0 99))  ;; [99 20 30 40]
v                           ;; still [10 20 30]

(vec-ref v3 0)              ;; 99
(vec-length v3)             ;; 4
(vec-filter #(> & 15) v3)   ;; [99 20 30 40]
(vec-slice v3 1 2)          ;; [20 30]: start at 1, take 2
(vec-map #(+ & 1) v)        ;; [11 21 31]
(vec-fold + 0 v)            ;; 60
(vec-merge v v)             ;; [10 20 30 10 20 30]

(list->vec xs) and (vec->list v) convert between the two.

Arrays

An Array is a plain .NET array: mutable, fixed in length, and exactly what C# code expects. Use them for performance-sensitive code, or when talking to .NET.

(def a #[1 2 3])
(array-set! a 0 42)        ;; changes a in place
(array-ref a 0)            ;; 42
(array-length a)           ;; 3
(def zeroes (make-array 10))

Every evaluation of an array literal makes a fresh array, so a function that returns #[0 0] returns a new one each time.

Maps

Map is an immutable hash map. The #map(...) literal takes entries written as (key value), [key value] or (key . value).

(def scores #map(("ada" 3) ("bo" 5)))
(def scores2 (map-set scores "cy" 1))   ;; scores still has two entries

(map-ref scores2 "cy")          ;; 1, and throws if the key is missing
(map-try-ref scores "cy")       ;; None
(map-ref-or scores "cy" 0)      ;; 0
(map-contains? scores2 "bo")    ;; #t
(map-length scores2)            ;; 3
(map-remove scores2 "ada")      ;; a map without ada

;; The callbacks of the map- functions get the key and the value as
;; two separate arguments.
(map-fold (fun (k v acc) (+ v acc)) 0 scores2)   ;; 9
(map-map-values #(* & 100) scores)                ;; ada 300 and bo 500
(map-filter (fun (k v) (> v 2)) scores2)          ;; ada and bo, not cy

Any type with an Eq can be a key. That includes your own types, which is one reason to derive Eq.

When a map is walked as a collection, for example in a loop or with fold, each element is a (Tuple key value) pair:

(loop (:for (name . score) scores)
      (:do (println #"${name}: ${score}")))

Sets and ordered collections

Sets and the sorted collections are libraries, so they have to be imported. Their functions are named like the map functions, with their own prefix:

(import (std set))
(import (std orderedmap))

(def s (list->set (list 3 1 4 1 5 9 2 6 5)))
(set-length s)                   ;; 7
(set-contains? s 4)              ;; #t
(set-contains? (set-add s 7) 7)  ;; #t

(def prices (orderedmap-set
              (orderedmap-set
                (orderedmap-set (orderedmap-empty) "pear" 3)
                "apple" 5)
              "fig" 1))
(seq->list (orderedmap-keys prices))   ;; '("apple" "fig" "pear"): always sorted
(orderedmap-min prices)                ;; (Some ("apple" . 5))

An ordered collection sorts its keys with their Ord, so a type that derives Ord can be a key in one. Strings are sorted by code unit, not by the rules of any language, so the order is the same on every machine.

Building collections quickly

Updating an immutable collection one element at a time is cheap, but not free. When you build a collection from scratch in a loop, use a builder, which is a mutable collection you fill and then freeze. There are builders for lists, vecs, strings, maps and sets:

(def vb (vecbuilder-empty))
(vecbuilder-add! vb "a")
(vecbuilder-add! vb "b")
(vecbuilder->vec vb)              ;; ["a" "b"]

(def lb (listbuilder-empty))
(listbuilder-add! lb 1)
(listbuilder-add! lb 2)
(listbuilder->list lb)            ;; '(1 2): builds front to back

A transient is the same thing for changing an existing map or set many times: it takes the collection, lets you mutate it, and gives you a new immutable collection back.

(def t (map->transientmap scores))
(transientmap-set! t "eve" 7)
(transientmap-remove! t "ada")
(def new-scores (transientmap->map t))   ;; bo and eve; scores is untouched

In practice you rarely write either of these directly, since the accumulators of loop (next section) use builders for you.

Generic operations

Many operations are traits, so they work on every collection that implements them:

FunctionWorks onDoes
(length c) (empty? c)lists, vecs, arrays, maps, stringssize, and whether it is empty
(ref c key)vecs, arrays, maps, stringsthe element at an index or key; throws
(try-ref c key)the samethe element as an Option
(add c x) (add-all c xs)vecs, maps, setsa new collection with more in it
(add! b x) (add-all! b xs)builders and transientsput things in, in place
(fold f init c)anything you can loop overa left fold
(map f c) (filter f c)the samea lazy Seq, see below
(any? f c) (all? f c) (find f c)the samesearch
(length [1 2 3])                        ;; 3
(ref [10 20 30] 1)                      ;; 20
(try-ref #map((:a 1)) :b)               ;; None
(add #map(("a" 1)) ("b" . 2))           ;; for a map, the element is a pair
(add-all (set-empty) [1 1 2 2 3])       ;; a set of three
(fold + 0 [1 2 3 4])                    ;; 10
(find #(> & 3) (list 1 5 7))            ;; (Some 5)

(def b (vecbuilder-empty))
(add-all! b (range 0 5))
(vecbuilder->vec b)                     ;; [0 1 2 3 4]

Mutable collections

For the rare cases where you really want a collection that is changed in place and shared, there are mutable versions under (std mutable ...): vec, map, set, deque and heap. The mutable vec is a .NET List<T>, and the mutable map is a .NET Dictionary.

(import (std mutable vec))

(def mv (mutablevec))
(mutablevec-add! mv 3)
(mutablevec-add! mv 1)
(mutablevec-add! mv 2)
(mutablevec-sort! mv)
(mutablevec-ref mv 0)      ;; 1

Reach for these only when you have measured that you need them, or when a .NET API wants one. A mutable collection cannot be a map key, and sharing one between fibers is your own problem.

Equality

Equality is structural. Two collections are equal when they hold equal elements, however they were built:

(= [1 2 3] (vec-add [1 2] 3))                     ;; #t
(= (list 1 2) (list 1 2))                         ;; #t
(= #map(("a" 1)) (map-set (map-empty) "a" 1))     ;; #t

eq? asks whether two things are the very same object. You almost never want it.

Example: a deck and a score table

A deck is a (Vec Card). Building it is a nested loop over suits and ranks (loops are the next section):

(: all-suits (Vec Suit))
(def all-suits [Clubs Diamonds Hearts Spades])

(: all-ranks (Vec Rank))
(def all-ranks [(Pip 2) (Pip 3) (Pip 4) (Pip 5) (Pip 6) (Pip 7) (Pip 8)
                (Pip 9) (Pip 10) Jack Queen King Ace])

(: full-deck (-> (Vec Card)))
(defun (full-deck)
  (loop (:for s all-suits)
        (:subloop)
        (:for r all-ranks)
        (:acc deck (vecing (Card (rank r) (suit s))))
        => deck))

(vec-length (full-deck))   ;; 52

The scores of a game are a map from player name to points. Since a map is immutable, a round of scoring is a function from the old table to a new one:

(: add-points (-> (Map string int) string int (Map string int)))
(defun (add-points table who points)
  (map-set table who (+ points (map-ref-or table who 0))))

(def start #map(("ada" 0) ("bo" 0)))
(def after (add-points (add-points start "ada" 5) "cy" 2))
(map-ref after "ada")    ;; 5
(map-ref after "cy")     ;; 2: a missing player starts at 0
(map-length start)       ;; still 2

Loops and sequences

There are three ways to repeat something in bjolang. A named let (or any tail-recursive function) is the scheme way, and was covered earlier. loop is the way you will use most of the time. seq and seql make lazy sequences, which produce their elements only when someone asks for them.

loop

A loop is a list of clauses that run in order, once per element. (:for x coll) walks anything that can be walked: lists, vecs, arrays, maps, strings, ranges and sequences. An accumulator collects a result, and => expr says what the loop returns:

(loop (:for x (list 1 2 3 4 5 6))
      (:when (even? x))
      (:acc out (listing (* x x)))
      => out)
;; '(4 16 36)

The clauses are:

ClauseDoes
(:for pat coll)binds pat to each element of coll in turn
(:with pat start update)a variable that starts at start and is updated every round
(:while test)keeps going while test holds, checked before every round
(:until test)keeps going until test holds, checked before every round
(:let pat expr)binds something for the rest of this round
(:when test)skips the rest of this round unless test holds
(:when-let pat expr)binds, and skips the round when the pattern does not match
(:finish test [value])ends the loop right away when test holds
(:finish-let pat expr)binds, and ends the loop when the pattern does not match
(:final test)ends the loop after this round when test holds
(:abandon test)ends a nested loop, and goes on with the next round of the outer one
(:do expr ...)runs something for its effect
(:acc name (collector x))collects x
(:subloop)between two :fors: nests them
(:subloop clause ...)a loop inside the round, whose accumulators the round can use
=> exprthe value of the loop (last, optional)

Since :for takes a pattern, you can take elements apart as you go, like the tuples of a map or the pairs enumerate makes:

(loop (:for (i . s) (enumerate ["a" "b"]))
      (:acc out (listing #"${i}:${s}"))
      => out)
;; '("0:a" "1:b")

Accumulators

An accumulator names a collector and what to feed it:

CollectorCollects
(listing x)a List, in order
(vecing x)a Vec
(mapping (k . v))a Map, from pairs
(setting x)a Set, from (std set)
(stringing c)a string, from characters
(summing x)the sum of x
(counting x)how many rounds reached it
(folding init expr)starts at init; each round, expr is the new value

folding is the general one: inside expr the accumulator’s own name is the value so far.

(def xs [3 9 2 7])

(loop (:for x xs) (:acc best (folding 0 (max best x))) => best)   ;; 9

(loop (:for c "hello") (:acc s (stringing (char-upcase c))) => s)   ;; "HELLO"

(loop (:for k (list "a" "b"))
      (:for v (list 1 2))
      (:acc m (mapping (k . v)))
      => m)                                     ;; a map from "a" to 1 and "b" to 2

An accumulator does not need a name. Without a =>, a loop returns its accumulators, and with several of them it returns a tuple of their values in order:

(loop (:for x xs) (:acc (summing x)) (:acc (counting x)))   ;; (21 . 4)

Several collections: lockstep and nesting

Two :for clauses in a row walk their collections side by side, and the loop ends when the shortest one runs out. To get every combination instead, put a (:subloop) between them. Everything after it runs once for every element of the inner collection, for every element of the outer one:

;; side by side
(loop (:for k (list "a" "b" "c"))
      (:for v (list 1 2 3))
      (:acc out (listing (k . v)))
      => out)                 ;; '(("a" . 1) ("b" . 2) ("c" . 3))

;; nested
(loop (:for x (list 1 2))
      (:subloop)
      (:for y (list 10 20))
      (:acc out (listing (+ x y)))
      => out)                 ;; '(11 21 12 22)

(:abandon test) in the inner loop ends it early, and the outer loop goes on with its next round. In the outer loop there is nothing to go on with, so there it is an error:

(loop (:for x (list 1 2 3))
      (:subloop)
      (:for y (list 1 2 3))
      (:abandon (> y x))
      (:acc out (listing (x . y)))
      => out)                 ;; '((1 . 1) (2 . 1) (2 . 2) (3 . 1) (3 . 2) (3 . 3))

A nested loop like this runs to the end of the round: the clauses after the inner :for belong to the inner loop. When the round should go on after the inner loop, with what it computed, write the inner loop as a :subloop form instead. It is a loop of its own inside the round, and its accumulators are there for the clauses after it, with their final values:

(loop (:for row (list (list 1 2) (list 3 4 5)))
      (:subloop (:for x row)
                (:acc sum (summing x))
                (:acc n (counting x)))
      (:acc out (listing (sum . n)))
      => out)                 ;; '((3 . 2) (12 . 3))

With => (name expr) at its end, only name is bound after the form, to expr:

(loop (:for row rows)
      (:subloop (:for x row)
                (:acc sum (summing x))
                (:acc n (counting x))
                => (avg (if (= n 0) 0 (/ sum n))))
      (:acc out (listing avg))
      => out)

The form’s accumulators start over every time it runs, and a form that runs zero times leaves them at their start values. Its own variables are never visible after it. Inside it, two clauses are about the form:

:when, :abandon, :while and :until inside the form are about the form’s own rounds. :finish still ends the whole loop.

A variable inside a loop cannot reuse the name of an accumulator, of a :with, or of a variable of an outer loop: (:for x xs) (:subloop) (:for x x) is an error. Pick another name.

State that carries over: :with

(:with name start update) is a variable that starts at start and is set to update after every round. All the :with updates of a round happen at once, so each of them sees the old values of the others:

(loop (:with a 0 b)
      (:with b 1 (+ a b))
      (:for i (range 0 10))
      (:acc out (listing a))
      => out)
;; '(0 1 1 2 3 5 8 13 21 34)

Stopping early

:finish leaves the loop before the rest of the round runs, and :final leaves it after. :when-let and :finish-let bind a pattern that may fail, and skip the round or leave the loop when it does. The names follow one rule: finish stops and goes on after the loop, abandon stops and goes up to the next round of the outer loop.

(loop (:for x (list 1 2 3 4 5)) (:finish (> x 3)) (:acc out (listing x)) => out)
;; '(1 2 3)

(loop (:for x (list 1 2 3 4 5)) (:final (> x 3)) (:acc out (listing x)) => out)
;; '(1 2 3 4)

;; Sum the strings that are numbers, and skip the rest.
(loop (:for s (list "1" "x" "3"))
      (:when-let (Ok n) (try (string->int s) #:catch (System.FormatException)))
      (:acc total (summing n))
      => total)
;; 4

;; Number the lines of a port until it is empty. up-from without a #:to
;; never stops on its own, so the :finish-let is what ends the loop.
(loop (:for line-number (up-from 1))
      (:finish-let (Some line) (read-line/opt port))
      (:do (println #"${line-number}: ${line}")))

(:finish test value) leaves the loop with value as its result, and the => expression is not run. The value has to have the same type as the => expression:

;; The sum, or -1 as soon as a negative number shows up.
(loop (:for x (list 1 2 -3 4))
      (:finish (< x 0) -1)
      (:acc total (summing x))
      => total)
;; -1

:break and :break-let are the old names of :finish and :finish-let. They still work, with a warning.

Looping while something holds: :while and :until

(:while test) keeps the loop going while test holds, and (:until test) until it does. Neither binds anything, so a loop that has no collection to walk can start with one:

(loop (:until (port-eof? port))
      (:let line (read-line port))
      (:do (println line)))

The test is checked before every round, the first one too, so the loop may run zero times. Next to a :for, the loop ends at whichever stops first, and the test can use the :for’s variable:

(loop (:for x (list 1 2 3 4 5))
      (:until (> x 3))
      (:acc out (listing x))
      => out)
;; '(1 2 3)

Since the test runs before the round, it cannot see what the round computes. To stop on that, put a :finish after it: (:let line (read-line/opt port)) (:finish (none? line)).

A loop has to start with a :for, a :with, a :while or an :until, since the other clauses belong to the round that it opens.

Counting and walking part of a collection

(:for x coll) walks all of coll from the front. These walk something else:

IteratorWalks
(range lo hi)the ints from lo up to, but not including, hi
(range-by lo hi step)the same, in steps of step
(up-from n #:to t #:by b)counts up from n, stopping before t; without #:to it never stops
(down-from n #:to t #:by b)counts down from n, stopping at t
(in-vec v #:from f #:to t)part of a vec
(in-reverse-vec v)a vec, last element first
(in-array a #:from f #:to t)part of an array, and in-reverse-array backwards
(over-list xs)the tails of a list: '(1 2 3), '(2 3), '(3)
(in-port p read)what read returns from a port, until it returns None
(loop (:for i (up-from 0 #:to 10 #:by 3)) (:acc out (listing i)) => out)  ;; '(0 3 6 9)
(loop (:for i (down-from 5 #:to 0)) (:acc out (listing i)) => out)        ;; '(5 4 3 2 1 0)
(loop (:for x (in-reverse-vec [1 2 3 4])) (:acc out (listing x)) => out)  ;; '(4 3 2 1)
(loop (:for x (in-vec [1 2 3 4] #:from 1 #:to 3)) (:acc out (listing x)) => out)  ;; '(2 3)

None of these allocate anything. The loop compiles to the same code you would have written by hand.

Named loops

A loop can be given a name, which is then bound to a function that continues with the next round. Calling it with keyword arguments overrides the value of an accumulator or a :with for the next round. It must be called in tail position:

(loop lp (:for x [1 2 3 4])
         (:acc total (summing x))
         (:do (if (= x 2)
                  (lp #:total 100)   ;; reset the sum to 100 after the 2
                  (lp)))
         => total)
;; 107

Comprehensions

A comprehension is a short way to write a loop with one accumulator. It is written in braces, with the collector first, then the expression to collect, then the clauses:

{listing (* a a) (:for a (range 0 5))}               ;; '(0 1 4 9 16)
{listing a :when (even? a) (:for a (range 0 10))}    ;; '(0 2 4 6 8)
{summing x (:for x [1 2 3])}                         ;; 6
{vecing (k . v) (:for k (list "a" "b")) (:for v (list 1 2))}
;; [("a" . 1) ("b" . 2)]

A loose :when right after the expression filters what is collected. Every loop clause works inside a comprehension, except :acc and =>.

Lazy sequences: seq and yield

A (Seq %a) is a sequence that computes its elements when they are asked for. You write one with seq, whose body calls yield for each element:

(: countdown (-> int (Seq int)))
(defun (countdown n)
  (seq
    (let loop ((i n))
      (when (> i 0)
        (yield i)
        (loop (- i 1))))
    (yield 0)))

(seq->list (countdown 3))   ;; '(3 2 1 0)

;; yield-from yields everything in another sequence.
(seq->list (seq (yield 100) (yield-from (countdown 2)) (yield 200)))
;; '(100 2 1 0 200)

Since nothing runs until it is asked for, a sequence can be endless, as long as you only ever take part of it:

(: naturals (-> (Seq int)))
(defun (naturals)
  (seq (let loop ((i 0)) (yield i) (loop (+ i 1)))))

(seq->list (seq-take (naturals) 5))                  ;; '(0 1 2 3 4)
(seq->list (seq-take (seq-filter even? (seq-map #(* & 3) (naturals))) 4))
;; '(0 6 12 18)

A sequence is re-run from the start every time it is walked. If computing the elements is expensive, turn it into a vec or list once with seq->vec or seq->list.

seql is loop made lazy. It takes the same clauses, with (:yield expr) instead of an accumulator:

(: fibs (-> (Seq int)))
(defun (fibs)
  (seql (:with a 0 b)
        (:with b 1 (+ a b))
        (:yield a)))

(seq->list (seq-take (fibs) 10))   ;; '(0 1 1 2 3 5 8 13 21 34)

The same thing as a comprehension is {seqing expr clause ...}.

map, filter and friends

The generic map, filter, take, drop, take-while, drop-while, enumerate and flat-map work on anything you can loop over, and all of them return a lazy Seq. Chains of them are fused by the compiler into a single pass. To get a concrete collection at the end, convert it:

(seq->list (map #(* & 2) (list 1 2 3)))   ;; '(2 4 6)
(seq->vec (filter even? [1 2 3 4]))       ;; [2 4]
(seq->list (take 3 (naturals)))           ;; '(0 1 2)

When you want a list or a vec out directly, the collection-specific functions (list-map, vec-filter and so on) are eager and return the same kind of collection they were given.

Example: dealing and counting

Dealing a deck to four players means giving player 0 the cards 0, 4, 8 and so on. That is a loop over players with a loop over the hand inside it:

(: deal (-> (Vec Card) int int (Vec (Vec Card))))
(defun (deal deck players per-hand)
  (loop (:for p (range 0 players))
        (:acc (vecing
                (loop (:for i (range 0 per-hand))
                      (:acc (vecing (vec-ref deck (+ p (* i players))))))))
        => hands))

(: points (-> Card int))
(defun (points c)
  (match c
    ((Card (rank (Pip n))) n)
    ((Card (rank Ace)) 11)
    (_ 10)))

(: hand-points (-> (Vec Card) int))
(defun (hand-points hand)
  {summing (points c) (:for c hand)})

(def hands (deal (full-deck) 4 5))
(vec-map hand-points hands)   ;; the points of each hand

Strings and text

Strings and characters

A string is a .NET string, which means it is stored as UTF-16. A char in bjolang is not a UTF-16 unit, though: it is a whole Unicode code point. An emoji is one char, even though it takes two units in the string. That leads to two different lengths:

(def s "Björn 😀 åt")
(string-length s)   ;; 11: storage units, O(1). Use it for sizing buffers.
(string-count s)    ;; 10: characters, O(n). This is "how long is the text".

For the same reason there is no string-ref that takes an index. Finding the n:th character of a UTF-16 string means walking it, so walking is what you do: with a loop, a fold, or a cursor.

(loop (:for c s) (:acc n (counting c)) => n)   ;; 10: loops go by character
(string->list "abc")                           ;; '(#\a #\b #\c)
(string-reverse "abc😀")                       ;; "😀cba", and the emoji survives

Characters are written #\a, #\space, #\newline. They can be compared with < and friends or char=?, converted with char->int and int->char, and classified with char-alphabetic?, char-numeric?, char-whitespace?, char-upper-case? and char-lower-case?.

Common operations

(string-append "ab" "cd")               ;; "abcd"
(str "a" "b" "c")                       ;; "abc": any number of strings
(string-upcase "björn")                 ;; "BJÖRN"
(string-trim "  hi  ")                  ;; "hi"
(string-pad-left "7" 3 #:with #\0)      ;; "007"
(string-split "a,b,,c" ",")             ;; ["a" "b" "" "c"]: a Vec
(string-join ["a" "b" "c"] ", ")        ;; "a, b, c"
(string-replace "banana" "an" "AN")     ;; "bANANa"
(string-contains? "banana" "nan")       ;; #t
(string-starts-with? "banana" "ban")    ;; #t
(string-empty? "")                      ;; #t

All of these compare strings ordinally, one storage unit at a time, and never according to the language settings of the machine. Upcasing and downcasing are the same everywhere too. To compare without caring about case, downcase both sides first.

Cursors

A cursor is a position in a string. It is as cheap as an int, but it always sits on a character boundary, and it can only be moved one character at a time, which is what makes it safe. Every cursor function takes the string as well as the cursor.

(: capitalise (-> string string))
(defun (capitalise s)
  (if (string-empty? s)
      s
      (let* ((start (string-cursor-start s))
             (rest (string-cursor-next s start)))
        (string-append (char->string (char-upcase (string-cursor-ref s start)))
                       (substring/cursors s rest (string-cursor-end s))))))

(capitalise "ölstuga")   ;; "Ölstuga"

;; string-index finds the first character matching a predicate, as a cursor.
(: first-word (-> string string))
(defun (first-word s)
  (match (string-index char-whitespace? s)
    ((Some c) (substring/cursors s (string-cursor-start s) c))
    (None s)))

(first-word "hello there world")   ;; "hello"

The folds walk a string for you, and are usually simpler than a cursor:

(: count-vowels (-> string int))
(defun (count-vowels s)
  (string-fold (fun (c n)
                 (if (string-contains? "aeiouy" (char->string (char-downcase c)))
                     (+ n 1)
                     n))
               0 s))

(count-vowels "Hello World")   ;; 3

Building strings

Appending strings in a loop copies the whole string every time. Use a string builder instead, or the stringing collector, which uses one for you:

(: join-words (-> (List string) string))
(defun (join-words words)
  (def sb (stringbuilder-empty))
  (loop (:for w words)
        (:do (when (> (stringbuilder-length sb) 0)
               (stringbuilder-add! sb #\space))
             (stringbuilder-add-string! sb w)))
  (stringbuilder->string sb))

Do not build strings by writing to a string port. Writing to a port is I/O as far as the compiler is concerned, and it will make every function that calls yours potentially asynchronous.

Converting to and from strings

->str turns anything into a string, and println and interpolation use it. It is a trait, so your own types can decide what they look like (the card game did that for Card). For the basic types there are also named conversions:

(int->string 42)         ;; "42"
(double->string 2.5)     ;; "2.5"
(->str 3.0)              ;; "3"
(string->int "42")       ;; 42, and throws on anything that is not a number
(string->double "1.5")   ;; 1.5
(char->string #\a)       ;; "a"
(string->symbol "hi")    ;; 'hi
(keyword->string :hi)    ;; "hi"

These all use the invariant culture: a number written on one machine reads back on any other, and 1.5 is never written as 1,5.

Formatting with (std fmt)

For output where the layout matters, (std fmt) has two layers. The simple one prints a vec of mixed values:

(import (std fmt))

(println* ["Hello, " 42 ", ok? " #t])   ;; Hello, 42, ok? #t

The other one is a small layout language. A layout is a (Vec Block), where a block is a string, a number, a character or one of the combinators, and show prints it:

(show ["[" (padded/left 6 ["foo"]) "]" nl])      ;; [   foo]
(show ["[" (padded/right 6 ["foo"]) "]" nl])     ;; [foo   ]
(show [(numeric/comma 1234567.891) nl])          ;; 1,234,567.891
(show [(numeric 3.14159 #:precision 2) nl])      ;; 3.14
(render->string [(trimmed/right 5 ["abcdefgh"])]) ;; "abcde"

(show [(joined/last (fun (s) (each [s]))
                    (fun (s) (each ["and " s]))
                    (list "a" "b" "c")
                    [", "])
       nl])                                      ;; a, b, and c

;; tabular takes columns, and sizes each to its content.
(show [(tabular [["name" nl "ada" nl "bo"]
                 ["score" nl 12 nl 7]]
                #:sep "  ")])
;; name  score
;; ada   12
;; bo    7

The full set of combinators, paragraphs and settings is described in the reference for (std fmt).

Regular expressions with (std rx)

Regular expressions are written as s-expressions with the #rx(...) hash macro, not as strings. There is no escaping, and a broken pattern is a compile error rather than a runtime one:

(import (std rx))

(def date-rx #rx(seq :bos
                     (=> :year (= 4 :ascii-digit))
                     "-" (=> :month (= 2 :ascii-digit))
                     "-" (=> :day (= 2 :ascii-digit))
                     :eos))
(def number-rx #rx((+ :ascii-digit)))

(rx-match? number-rx "123")                     ;; #t: the whole string
(rx-search? number-rx "abc 123")                ;; #t: anywhere
(rx-split #rx((+ :space)) "a  b   c")           ;; ["a" "b" "c"]
(rx-replace number-rx "a1b22c" "#")             ;; "a#b#c"
(vec-map rx-text (rx-matches number-rx "a1b22c333"))   ;; ["1" "22" "333"]

The most useful building blocks are (seq ...), (or ...), (* r), (+ r), (? r), (= n r), (** min max r), (=> :name r) to capture, :bos and :eos for the ends of the string, and character classes like :digit, :alpha, :space and (in "abc"). Note that :digit is every digit in Unicode; use :ascii-digit for 0 to 9.

The Rx and RxIn patterns use a regex inside a match. Rx matches the whole string and RxIn searches in it:

(: year-of (-> string string))
(defun (year-of s)
  (match s
    ((Rx date-rx (Some m)) (option-value (rx-group m :year) "?"))
    ((RxIn number-rx (Some m)) #"some number: ${(rx-text m)}")
    (_ "no date")))

(year-of "2026-09-30")   ;; "2026"
(year-of "room 101")     ;; "some number: 101"

Define patterns at the top level, as above, rather than writing #rx(...) inside a clause. Otherwise the regex is recompiled at every call, which will tank performance.

Traits

A trait is a set of functions that a type can implement. It is how bjolang does what other languages do with interfaces, type classes or overloading: = is a trait method, so is ->str, and so are length, fold and compare.

Declaring and implementing a trait

def/trait declares a trait. It names a type variable, the implementor, and gives the signatures of its methods. impl implements it for one type:

(def/trait (Describe %a)
  (: describe (-> %a string)))

(impl (Describe Suit)
  (defun (describe s)
    (match s
      (Clubs "clubs") (Diamonds "diamonds") (Hearts "hearts") (Spades "spades"))))

(impl (Describe Rank)
  (defun (describe r)
    (match r
      ((Pip n) (int->string n))
      (Jack "jack") (Queen "queen") (King "king") (Ace "ace"))))

(impl (Describe Card)
  (defun (describe c)
    ;; These two calls go to the Rank and the Suit implementations.
    #"the ${(describe (record-ref c rank))} of ${(describe (record-ref c suit))}"))

(describe (Card (rank Queen) (suit Hearts)))   ;; "the queen of hearts"

A method is called like any other function. Which implementation runs is decided by the type of the argument, at compile time when the type is known there.

Default methods

A trait may give a method a body. An implementation then gets that method for free, but may still write its own:

(def/trait (Describe %a)
  (: describe (-> %a string))
  (: shout (-> %a string))
  (defun (shout x) (string-upcase (describe x))))

(impl (Describe Suit)                    ;; shout comes from the trait
  (defun (describe s)
    (match s
      (Clubs "clubs") (Diamonds "diamonds") (Hearts "hearts") (Spades "spades"))))

(impl (Describe Card)                    ;; this one writes its own shout
  (defun (describe c)
    #"the ${(describe (record-ref c rank))} of ${(describe (record-ref c suit))}")
  (defun (shout c) "A CARD!"))

(shout Spades)                           ;; "SPADES"
(shout (Card (rank Queen) (suit Hearts)))   ;; "A CARD!"

Constraints

A generic function that calls a trait method has to say that its type variable implements the trait. That is written as a where clause at the end of the signature:

(: describe-all (-> (Vec %a) (Vec string)) (where (Describe %a)))
(defun (describe-all xs) (vec-map describe xs))

(describe-all [Ace (Pip 3)])     ;; ["ace" "3"]

(: highest (-> (List %a) (Option %a)) (where (Ord %a)))
(defun (highest xs) (list-max xs))

describe-all can now be called with a vec of anything that has a Describe, and calling it with anything else is a compile error at the call.

For Eq and ->str you may leave the where out: the compiler adds them by itself when a function uses = or ->str on a type variable.

Associated types

A trait has exactly one implementor type. When a method needs another type that depends on the implementor, such as the element type of a collection, the trait declares an associated type with (type %name), and each implementation says what it is:

(def/trait (Container %c)
  (type %item)
  (: first-item (-> %c (Option %item))))

(impl (Container (List %a))
  (type %item %a)
  (defun (first-item xs)
    (match xs
      (() None)
      ((Cons x _) (Some x)))))

(impl (Container string)
  (type %item char)
  (defun (first-item s)
    (if (string-empty? s)
        None
        (Some (string-cursor-ref s (string-cursor-start s))))))

(first-item (list 1 2))   ;; (Some 1)
(first-item "xyz")        ;; (Some #\x)

This is how the prelude’s own Iterable, Collection and Refable work, and why length and fold work on so many types.

Conditional and blanket implementations

An implementation for a generic type can require something of its type variables, with a where after the head. A list can be described if its elements can:

(impl (Describe (List %a))
  (where (Describe %a))
  (defun (describe xs)
    (string-join (list->vec (list-map describe xs)) ", ")))

(describe (list Clubs Hearts))   ;; "clubs, hearts"

A blanket implementation is one for a bare type variable. It applies to every type that has no implementation of its own, and a more specific implementation always wins over it. Only the module that declares a trait may write its blanket:

(def/trait (Weight %a)
  (: weight (-> %a int)))

(impl (Weight %a)                 ;; everything weighs 1 ...
  (defun (weight x) 1))

(impl (Weight string)             ;; ... except strings
  (defun (weight s) (string-count s)))

(weight 42)        ;; 1
(weight "hello")   ;; 5

Eq, Ord and ->str

Three traits from the prelude are worth knowing by heart, since the rest of the library leans on them.

TraitMethodsGives you
Eq=, eq-hashequality, and use as a key in maps and sets
Ordcompareless?, least, list-sort, list-max, ordered collections
->str->strprintln, print, string interpolation

Eq and Ord can be derived with type/derive, as shown earlier. When you write them by hand, they must be written in the module that declares the type, because they are compiled into the type itself: they become its .NET Equals, GetHashCode and CompareTo, which is why a .NET dictionary or a sorted collection agrees with = and compare. = and eq-hash must agree: two values that are equal must have the same hash. compare returns a negative number, zero or a positive number.

(impl (Eq Card)
  (defun (= a b)
    (and (= (record-ref a rank) (record-ref b rank))
         (= (record-ref a suit) (record-ref b suit))))
  (defun (eq-hash c)
    (hash-combine (eq-hash (record-ref c rank)) (eq-hash (record-ref c suit)))))

->str has a blanket implementation that uses .NET’s ToString, which is why printing one of your own types shows its C# name until you implement it.

Values of different types in one collection: dyn

A list holds values of one type. When you need a collection of different types that all implement the same trait, pack each value as a (dyn Trait):

(: render-all (-> (List (dyn ->str)) (List string)))
(defun (render-all xs) (list-map ->str xs))

(render-all (list (dyn ->str 1)
                  (dyn ->str "two")
                  (dyn ->str (Card (rank Queen) (suit Hearts)))))
;; '("1" "two" "Q♥")

Packing is always explicit, and a dyn cannot be turned back into the value it came from. It is the exception rather than the rule: most code is better served by a union.

Traits that are .NET interfaces

Some traits are not implemented in bjolang at all, but stand for a .NET interface, which the type either implements or does not. The numeric traits Num, Integral and Ordered are like that, and are described in the next section. Such a trait is declared with #:clr-constraint, and each method names the interface member it stands for:

(def/trait (Float %a)
  (#:clr-constraint (System.Numerics.IFloatingPointIeee754 %a))
  (: nan?    (-> %a bool) #:clr-member IsNaN)
  (: finite? (-> %a bool) #:clr-member IsFinite))

(nan? (/ 0.0 0.0))    ;; #t

You cannot write an impl of such a trait, and no type declared in bjolang can satisfy one.

Example: cards that sort

Deriving Ord sorts cards by rank, but by the order the cases are declared in. If you want a different order, for example suits in bridge order, spades highest, and aces low, you write compare yourself. Here the cards are compared by rank first, with ace as 1, and by suit when the ranks are equal:

(type (: Card (Struct (: rank Rank) (: suit Suit))))   ;; no type/derive this time

(: rank-value (-> Rank int))
(defun (rank-value r)
  (match r ((Pip n) n) (Ace 1) (Jack 11) (Queen 12) (King 13)))

(: suit-value (-> Suit int))
(defun (suit-value s)
  (match s (Clubs 0) (Diamonds 1) (Hearts 2) (Spades 3)))

(impl (Eq Card)
  (defun (= a b)
    (and (= (rank-value (record-ref a rank)) (rank-value (record-ref b rank)))
         (= (suit-value (record-ref a suit)) (suit-value (record-ref b suit)))))
  (defun (eq-hash c)
    (hash-combine (rank-value (record-ref c rank)) (suit-value (record-ref c suit)))))

(impl (Ord Card)
  (defun (compare a b)
    (def by-rank (compare (rank-value (record-ref a rank))
                          (rank-value (record-ref b rank))))
    (if (= by-rank 0)
        (compare (suit-value (record-ref a suit)) (suit-value (record-ref b suit)))
        by-rank)))

(list-sort (list (Card (rank Ace) (suit Clubs))
                 (Card (rank (Pip 7)) (suit Spades))
                 (Card (rank (Pip 7)) (suit Hearts))
                 (Card (rank King) (suit Diamonds))))
;; A♣ 7♥ 7♠ K♦

Nothing about list-sort knows about cards. It asks for (Ord %a), and now Card has one, so sorting, least, list-max, less? and ordered maps keyed by cards all work.

Numbers

The types

There are eight numeric types, each a .NET primitive:

TypeRangeTypeRange
byte0 … 255uint0 … 2^32−1
short−32 768 … 32 767long−2^63 … 2^63−1
ushort0 … 65 535ulong0 … 2^64−1
intabout ±2.1 billiondouble64-bit floating point

There is no float and no decimal. A char is not a number: it can be compared and converted, but not added.

Writing numbers

A number with a suffix is of that type. A decimal point or an exponent makes it a double:

WrittenType
21uybyte
21sshort
21usushort
21uuint
21Llong
21ULulong
21d 2.5 1e3double
0x2A 0b10101042, of whatever type the context says

A number without a suffix has no type of its own. It takes the type of wherever it is used, and is an int only when nothing says otherwise:

(: doubled (-> ushort ushort))
(defun (doubled x) (* x 2))    ;; the 2 is a ushort

(doubled 21)                   ;; 42, and 21 is a ushort too

(let ((start 5))
  (+ start 3L))                ;; 8L: start was a long all along

A literal that does not fit the type it ends up with is a compile error. (doubled 99999) says that 99999 does not fit in a ushort.

Arithmetic

+ - * / % are the C# operators and do what they do in C#. With more than two arguments they fold, so (+ 1 2 3) is 6. (- x) negates.

(/ 7 2)       ;; 3: integer division truncates
(/ 7.0 2.0)   ;; 3.5
(% -7 2)      ;; -1: the remainder takes the sign of the dividend
(% 7 -2)      ;; 1

Integer arithmetic wraps around on overflow, silently, as it does in C#:

(: bump (-> int int))
(defun (bump n) (+ n 1))

(bump 2147483647)   ;; -2147483648

Integer division by zero throws System.DivideByZeroException. Dividing a double by zero gives infinity, and (/ 0.0 0.0) is NaN. The bit operations are bitwise-and, bitwise-or, bitwise-xor, shift-left, shift-right and shift-right-logical.

Converting

There is no automatic conversion between numeric types. Multiplying an int variable by 1.5 is a type error:

(: scale (-> int double))
(defun (scale n) (* n 1.5))
;; Type error: these types do not match.
;;   int
;;   double

cast converts, with the target written as a .NET type. It behaves like a C# cast: converting a double to an integer rounds toward zero, and narrowing an integer wraps.

(: scale (-> int double))
(defun (scale n) (* (cast System.Double n) 1.5))

(: average (-> (Vec int) double))
(defun (average xs)
  (/ (cast System.Double (fold + 0 xs))
     (cast System.Double (vec-length xs))))

(cast System.Int32 2.7)    ;; 2
(cast System.Int32 -2.7)   ;; -2
(cast System.Int64 5)      ;; 5L

Converting to and from strings was covered in the section on text. Remember that string->int throws on bad input, so input that is not yours should go through try:

(: parse (-> string (Result System.Exception int)))
(defun (parse s) (try (string->int s) #:catch (System.FormatException)))

Comparing

There are two ways to compare, and they are for different things:

(compare 2.5 1.5)             ;; 1
(least "pear" "apple")        ;; "apple"
(list-sort (list 5 3 9))      ;; '(3 5 9)
(min 3 7)                     ;; 3, but only for numbers
(least 3 7)                   ;; 3, for anything with an Ord

Generic numeric code

A function that does arithmetic on a type variable needs a constraint that says the type is a number:

ConstraintAllowsHolds for
Num+ - * / %, numeric literalsevery numeric type
Ordered< > <= >=numbers and char
Integralthe bit operationsthe integer types
(: sum-all (-> (Vec %a) %a) (where (Num %a)))
(defun (sum-all xs) (fold + 0 xs))

(sum-all [1 2 3])       ;; 6
(sum-all [1.5 2.5])     ;; 4.0

(: low-bit (-> %a %a) (where (Integral %a) (Num %a)))
(defun (low-bit x) (bitwise-and x 1))

(low-bit 7)             ;; 1
(low-bit 8L)            ;; 0L

Unlike Eq, these constraints are never added for you. They are .NET interfaces, and no type you declare can ever implement them, so they say what a type is rather than what it can do, and bjolang wants that written down. The compiler tells you exactly what is missing, though:

Type Error at nums.bjo:2: 'low-bit' needs (Num %a), which its signature does not declare. [...] Write:
  (: low-bit ... (where (Integral %a) (Num %a)))

If a function only compares values, use (where (Ord %a)) instead. It works for strings and your own types too.

The maths functions

abs, sign, min, max, clamp, zero?, even?, odd? and sqrt are in the prelude. The rest of System.Math is in (std maths), and works on doubles:

(import (std maths))

(sqrt 9)            ;; 3.0: the int 9 widens to a double at the call
(pow 2.0 10.0)      ;; 1024.0
(floor -2.5)        ;; -3.0
(truncate -2.5)     ;; -2.0
(round 2.5)         ;; 2.0: banker's rounding, to the nearest even
(round 3.5)         ;; 4.0
(clamp 5 1 3)       ;; 3
(abs -5)            ;; 5

The logarithms are loge (natural), log2, log10 and (log-base x b). The name log is taken by the logging function of the prelude.

Writing programs

While the first part focused on the core language constructs in single files, this part covers the wider ecosystem: breaking programs into modules, managing project dependencies, handling I/O, concurrency with bjoroutines, .NET interop, macros, and testing.

Throughout this part we will continue building on the card game from Part 1, gradually turning it into a full project complete with a deck module, a persistent high-score file, concurrent players, and a custom #card(Q hearts) literal.

Modules

In Bjolang, every .bjo file is implicitly a module named after its file. There are no namespace declarations or enclosing module blocks; instead, a module simply exports whatever public API it wants other files to see, leaving everything else private by default.

Splitting the game into files

Here is the deck from the first part as a module of its own. Put it in deck.bjo:

(import (std random))

(export Suit Rank Card full-deck shuffle card-points)

(type/derive (Eq Ord)
  (: Suit (Union Clubs Diamonds Hearts Spades))
  (: Rank (Union (: Pip int) Jack Queen King Ace))
  (: Card (Struct (: rank Rank) (: suit Suit))))

(impl (->str Suit)
  (defun (->str s)
    (match s (Clubs "♣") (Diamonds "♦") (Hearts "♥") (Spades "♠"))))

(impl (->str Rank)
  (defun (->str r)
    (match r
      ((Pip n) (int->string n))
      (Jack "J") (Queen "Q") (King "K") (Ace "A"))))

(impl (->str Card)
  (defun (->str c) #"${(record-ref c rank)}${(record-ref c suit)}"))

;; Not exported: only this module needs them.
(def all-suits [Clubs Diamonds Hearts Spades])
(def all-ranks [(Pip 2) (Pip 3) (Pip 4) (Pip 5) (Pip 6) (Pip 7) (Pip 8)
                (Pip 9) (Pip 10) Jack Queen King Ace])

(: full-deck (-> (Vec Card)))
(defun (full-deck)
  (loop (:for s all-suits)
        (:subloop)
        (:for r all-ranks)
        (:acc deck (vecing (Card (rank r) (suit s))))
        => deck))

(: shuffle (-> (Vec Card) (Vec Card)))
(defun (shuffle deck) (shuffle-vec deck))

(: card-points (-> Card int))
(defun (card-points c)
  (match c
    ((Card (rank (Pip n))) n)
    ((Card (rank Ace)) 11)
    (_ 10)))

And the game, in game.bjo next to it:

(import "deck.bjo")

(defun (main)
  (def hand (vec-slice (shuffle (full-deck)) 0 5))
  (println #"Your hand: ${hand}")
  (println #"Points: ${(vec-fold (fun (c acc) (+ acc (card-points c))) 0 hand)}")
  0)

Running bjo run game.bjo outputs something like Your hand: [3♥ 10♠ 8♥ 10♦ 7♣].

Notice how the imports and exports interact here:

Every exported definition must have an explicit top-level type signature. The compiler needs this signature to write the metadata into the compiled .dll:

Export Error: Exported item 'helper' is missing a mandatory type signature at bad1.bjo:1

The standard library, and the prelude

A list-shaped module path such as (std random) refers to a standard library module. The compiler resolves these relative to the Bjolang installation rather than the current directory, so (std random) always points to the same library regardless of where you invoke bjo. A few notable modules include:

ModuleWhat it has
(std random)random numbers, shuffle-vec
(std set)sets; (std orderedset), (std orderedmap)
(std ports)reading a whole port: port->lines and friends
(std fmt)text layout
(std rx)regular expressions
(std run)running other programs
(std effect)effect handlers
(std simpletest)tests
(std syntax-match)writing macros
(text json)JSON

By default, (std prelude) is imported implicitly into every module, providing core functions like println, list-map, and the basic operations introduced in Part 1. If you need to hide or override names from the prelude, you can import it explicitly using import modifiers (described below), which replaces the default implicit import:

(import (except (std prelude) list-map))

Exporting types

Types are private unless listed in export. Furthermore, the compiler forbids exporting a function whose signature references an unexported private type, since importers would have no way to understand or satisfy that signature. The compiler catches this as an export error in the defining module.

When you want external modules to use a type without inspecting or constructing its internal representation directly, mark the type definition with #:opaque. For example, here is a card draw pile in pile.bjo that only permits drawing from the top:

(import "deck.bjo")
(export Pile new-pile draw pile-size)

;; Importers can hold a Pile, but only this module can look inside.
(type (: Pile #:opaque (Record (: cards (Vec Card)))))

(: new-pile (-> Pile))
(defun (new-pile) (Pile (cards (shuffle (full-deck)))))

(: pile-size (-> Pile int))
(defun (pile-size p) (vec-length (record-ref p cards)))

;; The top card and the rest of the pile, or None when it is empty.
(: draw (-> Pile (Option (Tuple Card Pile))))
(defun (draw p)
  (match (record-ref p cards)
    ([] None)
    ([top rest ...] (Some (Tuple top (Pile (cards rest)))))))

With this setup, another module can receive Pile values, use Pile in type annotations, and call functions like new-pile or draw. However, it cannot construct a pile directly with (Pile ...), access its internal cards field, or pattern-match on its structure:

Type Error at game3.bjo:4: 'cards' cannot be read here. pile/Pile is exported #:opaque,
so its representation is visible only to the code of pile. A value of it can be held and
passed on here, and built and taken apart through the functions that module exports.

Even though the type’s representation is private, any trait implementations defined in pile.bjo remain available to consumers. For instance, an (impl (->str Pile) ...) inside pile.bjo allows any importing module to convert a Pile to a string.

Import modifiers

Imports can be wrapped in modifiers to filter which definitions are brought into scope or rename them to prevent naming collisions:

ModifierDoes
(only m a b ...)only these functions and macros
(except m a b ...)everything but these
(prefix m "p/")puts p/ in front of every name
(postfix m "/p")the same, at the end
(prefix-defs m "p/")a prefix on functions and macros only
(prefix-types m "P/")a prefix on types, constructors and traits only
(rename m (old new) ...)renames functions and macros
(import "deck.bjo"
        (prefix "pile.bjo" "pile/"))

(defun (main)
  (match (pile/draw (pile/new-pile))
    ((Some (Tuple c rest)) (println #"Drew ${c}, ${(pile/pile-size rest)} left"))
    (None (println "empty")))
  0)

Modifiers can be nested and evaluate from the inside out. For example, (prefix (except (std set) set-map) "s/") excludes set-map and prefixes all remaining names with s/.

Note that only and except apply exclusively to functions and macros, not types. A module’s exported types are always imported, ensuring that signatures referencing those types remain valid and resolvable:

(import (only "deck.bjo" full-deck)
        (rename (std random) (shuffle-vec mix)))

(defun (main)
  (println (vec-length (mix (full-deck))))       ;; 52
  (println (Card (rank Ace) (suit Spades)))      ;; A♠, Card came along anyway
  0)

Types are strictly scoped to the module that declared them. If two imported libraries each declare their own Card type, they remain distinct types; the unqualified name Card simply resolves to whichever import came last. To disambiguate between them, use prefix-types:

(import (prefix-types "poker.bjo" "P/")
        (prefix-types "bridge.bjo" "B/"))

(: convert (-> P/Card B/Card))

Which name wins

When multiple definitions share the same identifier, name resolution follows three precedence rules:

re-export and :alias

While export publishes definitions authored in the current module, re-export publishes definitions that were imported from elsewhere. This makes it easy to create aggregator modules that bundle several sub-modules behind a single import. The prelude uses this technique to pass through =, compare, and list-sort from (std eq). In our card game:

;; cards.bjo: one import for everything about cards
(import "deck.bjo" "pile.bjo")

(re-export Card Rank Suit full-deck shuffle Pile new-pile draw)

Re-exporting a type exposes the exact original type, not a distinct copy; a Card referenced via cards.bjo is identical to one from deck.bjo. However, trait implementations (impl) do not transfer through re-exports. They remain bound to the module where they were defined, meaning any file that wants to format cards with ->str must still import deck.bjo.

You can provide alternative names for bindings or macros using (:alias new old). Combining :alias with export or re-export allows libraries to offer cleaner or more idiomatic public APIs:

(:alias deal full-deck)
(export deal)

include

Unlike import, which compiles a separate module, (include "file.bjo") textually splices the included file’s forms directly into the current file at compile time. No separate module boundary is created, no exports are required, and all declarations in the included file share the enclosing module’s scope:

;; helpers.bjo
(: double (-> int int))
(defun (double x) (* x 2))
(include "helpers.bjo")

(defun (main)
  (println (double 21))   ;; 42
  0)

As a general guideline: use include when you want to divide a single large module across multiple files, and use import when the target file represents an independent component with its own encapsulation boundaries.

Projects and packages

Up to this point, our programs have consisted of standalone files importing neighboring modules directly by relative path. While that approach is convenient for quick scripts and small experiments, any software that pulls in external dependencies or is meant to be consumed by other developers should be structured as a project.

A project is simply a directory containing a manifest.bjodat file. When operating inside a project directory, bjo understands the package’s identity, resolves dependencies, and manages builds automatically. Outside of a project, the CLI falls back to single-file execution just as before.

Making a project

mkdir cards && cd cards
bjo init
bjo run

Running bjo init scaffolds a new project layout:

cards/
  manifest.bjodat    package metadata and dependencies
  src/
    main.bjo         executable entry point
  tests/
  .gitignore

The generated manifest defines the package identity:

(package
  (name (cards))
  (version "0.1.0"))

All source files in a package live under src/, where each module’s name mirrors its relative path within that directory. For example, moving deck.bjo into src/ exposes it as the module (cards deck), which src/main.bjo can import just like any library module:

(import (cards deck))

(defun (main args)
  (println #"Your hand: ${(vec-slice (shuffle (full-deck)) 0 5)}")
  (println #"Arguments: ${args}")
  0)

Because bjo automatically detects the project root by walking upward until it finds a manifest.bjodat, you can invoke CLI commands from any sub-directory within the project:

CommandWhat it does
bjo init [--lib]Initializes a new project in the current directory
bjo fetchFetches external dependencies without compiling
bjo buildFetches dependencies and compiles the program or library
bjo run . args ...Builds and runs the project with the specified arguments
bjo checkType-checks the entire project without emitting output binaries
bjo replStarts the REPL preloaded with access to the project’s modules

In bjo run . one two, the dot specifies the project itself. Any arguments following the target are forwarded directly to the program’s main function, binding args to [one two]. You can also point bjo run at a specific test file, such as bjo run tests/deck-test.bjo; the file will be compiled and linked against the project’s declared packages.

Libraries

A package that lacks an executable entry point (src/main.bjo) is treated as a library. Running bjo init --lib initializes a library project, and running bjo build inside it compiles all modules found under src/.

To see this in action, we can split out our card deck into a separate library residing alongside the game:

mkdir cardlib && cd cardlib
bjo init --lib
mv ../cards/src/deck.bjo src/

We can then declare a path dependency in cards/manifest.bjodat and import the library module as (cardlib deck):

(package
  (name (cards))
  (version "0.1.0")
  (depends
    (package (name (cardlib))
             (source (path (dir "../cardlib"))))))
(import (cardlib deck))

(defun (main)
  (println (vec-slice (shuffle (full-deck)) 0 5))
  0)

Path dependencies are resolved relative to the manifest file that specifies them. Because a path dependency points straight to source files on disk, it does not require a version number—the build simply consumes whatever code is currently in the directory, making local development and iteration seamless.

Dependencies from git

Once a library is hosted in a Git repository, you can switch from a local path to a Git dependency:

(depends
  (package (name (cardlib))
           (version (version-at-least "0.1.0"))
           (source (git (url "https://github.com/someone/cardlib")))))

Package versions correspond to Git tags formatted strictly as vX.Y.Z. Git dependencies specify version constraints using (version-at-least "1.2"), (version-between "1.2" "2.0"), or (version-at-most "2.0").

Bjolang resolves versions using Minimal Version Selection (MVS). Rather than greedily selecting the latest release available in the wild, MVS inspects the minimum version requested by each package across the entire dependency graph, and then selects the highest of those minimum requirements. In other words, you get the oldest possible release that satisfies everyone’s stated requirements. This guarantees reproducible builds: new upstream releases won’t alter your build until someone explicitly raises their requirement in a manifest, ensuring your code compiles identically a year from now. Any upper bound constraints are treated strictly as validation checks, never as criteria to pick an older package.

Commands like bjo fetch, build, and run clone external dependencies into a local .bjo/ directory and record the exact resolved commits in bjo.lock. You should always check bjo.lock into version control, while leaving .bjo/ ignored (as configured in the default .gitignore). If an upstream repository rewires a tag to point to a different commit after it was locked, the build aborts immediately. On CI servers, pass --locked to guarantee that the build fails if any dependency deviates from the locked snapshot.

You can also override dependency sources: if your root manifest defines an explicit source for a package, that source overrides any locations requested deeper in the dependency tree. This makes it straightforward to substitute a local checkout or an emergency fork while debugging.

NuGet packages

You can pull in third-party .NET packages directly from NuGet by listing them in the packages section:

(package
  (name (shop))
  (version "0.1.0")
  (packages (nuget (id "Npgsql") (version "9.0.3"))))

Once restored, types and methods from NuGet packages can be consumed through Bjolang’s .NET interop mechanisms (described in the .NET section below). Version strings follow standard NuGet versioning semantics: "9.0.3" matches version 9.0.3 or higher, while bracketed syntax like "[9.0.3]" locks to that exact version. Bjolang delegates package restoration to the .NET SDK via the dotnet tool, storing the resolution graph in packages.lock.json (which should also be committed). Currently, compiled binaries load dependencies directly from the local NuGet cache, so executables expect to run in an environment with the restored packages available.

Publishing a package

Publishing a Bjolang package requires three basic steps:

Files and I/O

Whole files

For files that comfortably fit into memory, the prelude provides straightforward functions to read or write entire files in a single call:

FunctionDescription
(file-read-text path)Reads the entire file into a string
(file-write-text path s)Writes s, overwriting any existing contents
(file-append-text path s)Appends s to the file, creating it if needed
(file-read-lines path)Reads lines as a (Vec string)
(file-write-lines path lines)Writes a (Vec string), one line per element
(file-read-bytes path)Reads the raw bytes as an (Array byte)
(file-exists? path)Checks whether a file exists at the given path
(file-delete path)Deletes a file; succeeds silently if file is absent
(file-copy from to)Copies a file, raising an error if target exists
(file-move from to)Moves or renames a file or directory
(file-info path)Returns size, modification time, and kind as an Option

Because these wrap the underlying .NET I/O methods directly, any I/O failure (such as missing permissions or a missing file on read) throws a .NET exception. If you expect a failure under normal conditions, wrap the call in a try expression.

To illustrate, we can add a persistent high-score ledger to our card game. Each line records a player name and their score separated by a space. Malformed lines are safely discarded rather than crashing the program:

(: score-file string)
(def score-file "scores.txt")

(: parse-score (-> string (Option (Tuple string int))))
(defun (parse-score line)
  (match (string-split line " ")
    ([name points]
     (match (try (string->int points) #:catch (System.FormatException))
       ((Ok n) (Some (Tuple name n)))
       ((Err _) None)))
    (_ None)))

(: read-scores (-> (Vec (Tuple string int))))
(defun (read-scores)
  (if (file-exists? score-file)
      (loop (:for line (file-read-lines score-file))
            (:when-let (Some score) (parse-score line))
            (:acc (vecing score)))
      []))

(: save-score (-> string int void))
(defun (save-score name points)
  (file-append-text score-file #"${name} ${points}\n"))

(defun (main)
  (save-score "ada" 31)
  (save-score "bo" 27)
  (println (read-scores))    ;; [(ada, 31) (bo, 27)]
  0)

Paths, directories and the environment

Paths in Bjolang are represented as standard strings. The path manipulation utilities perform purely lexical operations on the string representations without touching the filesystem:

(path-combine "a" "b" "c.txt")        ;; "a/b/c.txt"
(path-file-extension "deck.bjo")      ;; (Some ".bjo")
(path-filename "/tmp/x/")             ;; None: it names a directory
(path-directory "/tmp/x/y.txt")       ;; (Some "/tmp/x")
(path-absolute "deck.bjo")            ;; the full path

When you do need to inspect or modify the filesystem layout, use the directory functions:

(directory-create "saves/2026")          ;; creates every missing directory on the way
(directory-exists? "saves")              ;; #t
(directory-files ".")                    ;; the files directly in ".", as a (Vec string)
(directory-subdirectories ".")           ;; and the directories
(directory-delete-tree "saves")          ;; everything under it, and it

;; There is no pattern argument. Filter instead:
(vec-filter #(string-ends-with? & ".bjo") (directory-files "."))

;; Every file under a directory, lazily, without going into .git:
(filter #(string-ends-with? & ".bjo")
        (directory-walk "." #:into? (fun (d) (not (= (path-filename d) (Some ".git"))))))

Process environment variables and working directory state can be inspected and updated similarly:

(get-environment-variable "HOME")      ;; (Some "/home/linus"), or None
(set-environment-variable! "MODE" "x") ;; for this process and the ones it starts
(current-directory)

Ports

When dealing with large files, streaming data, or incremental I/O, Bjolang uses *ports*. An input port provides a stream from which characters and lines can be read incrementally, while an output port collects written data. File ports and in-memory string ports share the same underlying interface, so functions written against ports work seamlessly with both files and string buffers.

Attempting to open a file returns a Result, treating potential I/O errors (such as missing files) as expected outcomes. The Err variant wraps the underlying .NET exception, allowing you to match on specific exception types with :is:

(match (open-input-file "nope.txt")
  ((Ok port) (read-all port))
  ((Err (:is System.IO.FileNotFoundException e)) #"missing: ${(.-FileName e)}")
  ((Err e) "some other error"))

To avoid leaking resources, you will usually want to use call-with-input-file or call-with-output-file. These higher-order functions open the port, pass it to your callback, and guarantee the port is cleanly closed when the function finishes—even if an exception is thrown. For example, counting lines in an arbitrary file looks like:

(: count-lines (-> string (Result Exception int)))
(defun (count-lines path)
  (call-with-input-file path
    (fun (port)
      (loop (:until (port-eof? port))
            (:let line (read-line port))
            (:acc lines (counting line))
            => lines))))

(count-lines "scores.txt")   ;; (Ok 2)
FunctionDescription
(open-input-file path)Returns a (Result Exception TextInputPort)
(open-output-file path)Opens for writing; #:mode can be Truncate, Append, or CreateNew
(read-line/opt p)Reads the next line, or returns None at EOF
(read-char/opt p)Reads the next character, or returns None at EOF
(read-all p)Reads the remaining contents into a single string
(port-eof? p)Checks if the end of the input stream has been reached
(write-string p s)Writes string s to the port
(writeln p s)Writes string s followed by a newline
(close-input-port p)Closes an input port
(close-output-port p)Closes an output port
(open-input-string s)Creates a port reading from an in-memory string
(open-output-string)Creates a string-builder port; read with get-output-string

The variants read-line and read-char (without the /opt suffix) also exist, but throw an end-of-file exception if called at EOF.

Convenience helpers in (std ports) can ingest an entire port into collections: port->lines, port->list, port->vec, as well as lazy sequences via port->seq and file->seq:

(import (std ports))

(call-with-input-file "scores.txt" port->lines)   ;; (Ok ["ada 31" "bo 27"])

In Bjolang, open file ports are also tracked by their enclosing structured concurrency scope (explained in the Concurrency section below). When a scope exits, any unclosed ports opened within it are automatically finalized. Even main acts as a root scope, ensuring leaked handles are collected when your process terminates. For long-lived processes, however, you should always explicitly close ports or rely on call-with-input-file.

Running other programs

The (std run) module lets you invoke external processes. Commands are written as S-expression forms using a concise DSL where pipelines and shell-style redirections are integrated directly into the syntax:

(import (std run))

(run/string '(echo "hello"))                              ;; (Ok "hello\n")
(run/strings '(pipe (cat "scores.txt") (sort -r)))        ;; (Ok ["bo 27" "ada 31"])
(run/status '(into-file "sorted.txt"
               (pipe (cat "scores.txt") sort)))           ;; (Ok 0), the exit code

(def who "ada")
(run/strings '(grep ,who "scores.txt"))                   ;; (Ok ["ada 31"])

Prefixing an identifier with a comma (e.g. ,who) splices the variable’s value into the command arguments. Each list element is treated as an individual command-line argument, preserving spaces without fragile string quoting or shell injection hazards. The DSL primitives pipe, into-file, from-file, append-to-file, and errors-into-file are validated at compile time; any unrecognized head identifier is executed as an external executable.

FunctionReturns
(run/string form)Standard output as a single trimmed string
(run/strings form)Standard output split into a vector of lines
(run/status form)Process exit code as an integer
(run/output form)Tuple of exit code and standard output string
(run form)A running Proc handle with streaming I/O ports

All of these functions return a Result and execute synchronously, blocking the calling thread until completion. When running inside a bjoroutine, non-blocking asynchronous alternatives are available; refer to the (std run) module documentation for details.

Concurrency

Bjolang’s concurrency model rests on three foundations: lightweight fibers called *bjoroutines*, communication channels, and structured concurrency scopes ensuring background work is cleanly contained. In addition, Bjolang adopts the Concurrent ML (CML) paradigm, where communication operations—such as sending or receiving on a channel—are first-class values that can be composed and combined before being synchronized.

Bjoroutines

Functions that may suspend execution—whether waiting on a channel, sleeping, or performing asynchronous I/O—are declared with defbjo rather than defun, and their type signatures use the -bjo-> arrow. Suspending and waiting for an event is performed using sync:

(: think (-bjo-> int int))
(defbjo (think n)
  (sync (timeout 50))     ;; wait 50 ms, without holding up a thread
  (* n n))

Bjoroutines execute as lightweight fibers scheduled across a managed thread pool. When a fiber suspends at a sync point, it yields its underlying thread back to the pool. As a result, maintaining thousands of waiting fibers incurs the memory overhead of small heap objects rather than expensive OS threads.

Because calling a suspending function means the caller may also have to wait, function coloring applies: bjoroutines can only be directly invoked from within other bjoroutines:

Type Error at colour.bjo:7: calling 'think' is a yield point, and a yield point is not allowed here.
  'twice' is defined with (defun ...), which is emitted as an ordinary C# method, and an ordinary
  method cannot await.
  Define it with (defbjo ...), or move the suspending call out of it. Note that (defbjo ...) spreads:
  whoever calls 'twice' needs to be one too.

To enter concurrent execution from the start, main can itself be defined with defbjo.

In practice, this coloring is less infectious than in many other languages. Most standard I/O functions (such as file-read-text) are automatically emitted with dual implementations: a synchronous blocking path and an asynchronous suspending path. An ordinary defun that calls standard I/O similarly gets both versions generated under the hood; when called from a bjoroutine, it transparently executes the non-blocking suspending version:

(: size-of (-> string int))
(defun (size-of path) (string-length (file-read-text path)))   ;; an ordinary defun

(defbjo (main)
  (println (size-of "deck.bjo"))    ;; the file is read without blocking a thread
  0)

spawn and channels

The (spawn (f args ...)) form creates a new concurrent fiber running the invocation (f args ...). Arguments are evaluated eagerly in the spawning fiber before transferring execution to the new fiber.

Fibers communicate over channels created with make-chan. Rather than performing immediate I/O, expressions like (chan-send ch v) and (chan-recv ch) construct first-class *events*, which are then synchronized using sync. Unbuffered channels operate as rendezvous points: sending blocks until a receiver is ready, and receiving blocks until a sender arrives.

We can model our card players as concurrent bjoroutines. Each player receives a channel representing their seat at the table and plays one card per trick (descending by value). The table (main) draws cards round-robin from each seat:

(import "deck.bjo")

(: player (-bjo-> (Vec Card) (Chan Card) void))
(defbjo (player hand seat)
  (loop (:for c (list-reverse (list-sort (vec->list hand))))
        (:do (sync (chan-send seat c)))))

;; One trick: a card from every seat, in turn.
(: play-trick (-bjo-> (Vec (Chan Card)) (Vec Card)))
(defbjo (play-trick seats)
  (loop (:for seat seats)
        (:acc (vecing (sync (chan-recv seat))))))

(defbjo (main)
  (def deck (shuffle (full-deck)))
  (def seats [(make-chan) (make-chan) (make-chan)])
  (loop (:for seat seats)
        (:for p (range 0 3))
        (:do (spawn (player (vec-slice deck (* p 4) 4) seat))))
  (loop (:for trick (range 1 5))
        (:do (println #"Trick ${trick}: ${(play-trick seats)}")))
  0)
Trick 1: [K♥ K♦ J♥]
Trick 2: [8♣ 7♣ 4♣]
Trick 3: [6♠ 4♥ 3♣]
Trick 4: [6♦ 3♦ 2♦]

Because channel sends and receives synchronize as a rendezvous, a player cannot get ahead of the table and play multiple cards into the same trick.

Events are values

Crucially, calling (chan-recv seat) does not perform the receive immediately; it simply constructs an event value describing the operation. The actual communication occurs only when the event is passed to sync. Because events are first-class values, they can be parameterized, composed, and transformed before being awaited:

FunctionDescription
(chan-recv ch)Receives a value from channel ch
(chan-send ch v)Sends v on channel ch
(timeout ms)Fires after ms milliseconds
(choose e1 e2 ...)Fires when the first of the supplied events occurs
(wrap e f)Fires when e occurs, applying f to its value

With these combinators, deadlines and timeouts become simple functions that compose over any event. For instance, we can enforce a response timeout on our card players:

(: within (-> int (Event %a) (Event (Option %a))))
(defun (within ms ev)
  (choose (wrap ev #(Some &))
          (wrap (timeout ms) (fun (u) None))))

(: slow-player (-bjo-> (Chan string) void))
(defbjo (slow-player seat)
  (sync (timeout 200))
  (sync (chan-send seat "7♣")))

(defbjo (main)
  (def seat (make-chan))
  (spawn (slow-player seat))
  (println (sync (within 50 (chan-recv seat))))    ;; None
  (println (sync (within 500 (chan-recv seat))))   ;; (Some 7♣)
  0)

Notice that within is defined with standard defun rather than defbjo. Simply constructing or composing event values does not suspend execution; suspending only occurs when an event is submitted to sync.

Also notice how the first timeout behaves: when the 50 ms deadline expires before the card arrives, the chan-recv event is cleanly cancelled without consuming the message. The card remains on the channel, available to be received by the subsequent 500 ms wait.

Collecting fiber results: bjo

While spawn runs a fiber for its side effects and discards its return value, bjo returns a joinable promise. You can await this promise using (promise-join p), which yields a Result wrapping either the computed value or an exception if the fiber failed:

(def a (bjo (think 3)))
(def b (bjo (think 4)))
(sync (promise-join a))    ;; (Ok 9)
(sync (promise-join b))    ;; (Ok 16)

Because both fibers execute concurrently across thread pool workers, the combined operations complete in roughly 50 ms rather than running sequentially for 100 ms.

Scopes

Every fiber runs within an enclosing structured concurrency scope. A scope cannot complete until all fibers spawned within it have terminated, ensuring background tasks never outlive their caller or leak into other components. Furthermore, unhandled exceptions inside spawned fibers propagate outward when the scope terminates rather than being silently dropped.

The top-level main function acts as an implicit root scope, which is why our earlier card game example cleanly awaited all player fibers. You can create nested scopes with fine-grained cancellation and deadlines using these forms:

FormDescription
(with-scope body ...)Waits for child fibers and finalizes open file ports
(with-cancel (cancel) body ...)Binds a cancel procedure to signal child fibers
(with-deadline ms body ...)Cancels the scope automatically after ms ms
(with-shield body ...)Shields cleanup operations from parent cancellation
(: fetch (-bjo-> string void))
(defbjo (fetch name)
  (sync (timeout 30))
  (println #"fetched ${name}"))

(: count-up (-bjo-> (Chan int) void))
(defbjo (count-up ch)
  (let go ((i 0))
    (sync (chan-send ch i))
    (go (+ i 1))))

(defbjo (main)
  ;; Both are fetched when this returns, or the deadline raises.
  (with-deadline 1000
    (spawn (fetch "a"))
    (spawn (fetch "b")))
  (println "both done")

  ;; count-up never ends on its own. Cancelling the scope stops it
  ;; the next time it waits.
  (def numbers (make-chan))
  (with-cancel (cancel)
    (spawn (count-up numbers))
    (println (sync (chan-recv numbers)))    ;; 0
    (println (sync (chan-recv numbers)))    ;; 1
    (cancel (Requested "enough")))
  0)

Cancellation in Bjolang is cooperative rather than preemptive. Cancelling a scope signals its fibers, which check for cancellation whenever they next perform a suspending operation (such as waiting on a channel or timer). A fiber that exits due to cancellation is treated as a clean completion rather than a failure.

Depending on the desired lifecycle and supervision strategy, fibers can be started using four distinct forms:

FormScope behavior
(spawn (f x))Awaited by scope; propagates failure on error
(bjo (f x))Awaited by scope; yields a promise holding value/failure
(spawn/daemon (f x))Cancelled on scope exit without being awaited
(spawn/detached (f x))Runs detached; unmonitored by the enclosing scope

Because scope forms suspend until their child fibers conclude, they must be called from within a bjoroutine.

Work that is not a fiber

Performing long CPU-bound computations or calling legacy blocking .NET APIs directly inside a fiber can monopolize thread pool threads, preventing other fibers from running. To avoid blocking the pool, you can offload such operations onto dedicated background threads; both helpers return synchronization events:

;; Work that waits: a call that parks its thread.
(sync (blocking #(file-read-text "deck.bjo")))                        ;; (Ok "...")

;; Work that computes: gets a thread of its own.
(sync (spawn/thread #(loop (:for i (range 0 1000)) (:acc (summing i)))))  ;; (Ok 499500)

Conversely, you may occasionally need to wait on a channel or event from inside synchronous code. While an ordinary defun cannot suspend with sync, it can block its caller thread using sync/blocking. Avoid calling sync/blocking inside a bjoroutine, as doing so freezes a thread pool worker that other fibers rely on:

(: wait-for-card (-> (Chan Card) Card))
(defun (wait-for-card ch)
  (sync/blocking (chan-recv ch)))

Effects

Our deck is shuffled using shuffle-vec, which is non-deterministic. While randomness is ideal during gameplay, it makes automated tests brittle when verifying deterministic outcomes like trick winners. The traditional object-oriented or functional workaround is dependency injection—passing an explicit shuffle function down through every layer of the call stack.

Bjolang provides an alternative mechanism: *algebraic effects*. With effects, a function simply declares and performs an abstract operation, allowing an enclosing caller higher up the call stack to dynamically decide how that operation is handled.

Declaring an effect

(import (std effect) "deck.bjo")

(defeffect Dealing
  (shuffle-deck (-> (Vec Card) (Vec Card)))
  #:default ((shuffle-deck shuffle)))

(: deal-hand (-> int (Vec Card)))
(defun (deal-hand n)
  (vec-slice (shuffle-deck (full-deck)) 0 n))

shuffle-deck is exposed as a regular function matching the declared signature. When called without an active handler in the dynamic scope, it falls back to the default implementation—in this case, the real shuffle. In a test, you can override this behavior using with-handler:

(deal-hand 5)                                     ;; five random cards
(with-handler ((shuffle-deck (fun (deck) deck)))
  (deal-hand 5))                                  ;; [2♣ 3♣ 4♣ 5♣ 6♣], every time

The handler intercepts operations executed anywhere within the dynamic extent of the with-handler block, including inside deeply nested helper calls and child fibers, without requiring deal-hand to pass any parameters around.

Several key semantics govern effect handlers:

(with-handler ((log (let ((outer (handler-of log)))
                      (fun (s) (outer (str "[game] " s))))))
  (log "dealing"))      ;; "[game] dealing" on stderr

The prelude’s own effects

Several core runtime facilities in the prelude are implemented as effects out of the box, allowing you to intercept or virtualize them in tests without modifying application code:

OperationDefault
(log s)Writes a line to standard error
(warn s)Writes a warning line to standard error
(getenv name)Reads an environment variable as an Option
(monotonic-ms)High-resolution monotonic clock for elapsed timing
The FS operationsPhysical filesystem operations

Because all prelude file I/O routes through the built-in FS effect operations, tests can replace the entire filesystem with an in-memory mock using with-fake-fs (covered in the Testing section below).

Similarly, you can capture diagnostic output in memory instead of dumping it to the terminal by installing a custom handler for log:

(def lines (make-box (list)))
(with-handler ((log (fun (s) (box-set! lines (Cons s (box-ref lines))))))
  (log "one")
  (log "two"))
(box-ref lines)    ;; '("two" "one")

Keep in mind that with-handler only establishes a dynamic binding; unlike a concurrency scope, it does not await background fibers. If code inside the handler spawns asynchronous fibers that emit logs, ensure the concurrency scope enclosing those fibers is placed *inside* the with-handler block so the handler remains active until all fibers finish writing.

.NET interop

Because Bjolang compiles directly to C#, you have full, seamless access to the entire .NET ecosystem. By declaring the CLR classes, methods, and properties you need along with their signatures, the compiler exposes them as ordinary Bjolang functions and types.

Methods: import/extern

For example, we can render suit symbols in color in the terminal using .NET’s System.Console:

(import "deck.bjo")

(import/class
  (ConsoleColor (: System.ConsoleColor)))

(import/extern
  (set-foreground! (: System.Console.ForegroundColor (-> ConsoleColor void) #:set))
  (reset-colour!   (: System.Console.ResetColor (-> void)))
  (console-write   (: System.Console.Write (-> string void))))

(: suit-colour (-> Suit ConsoleColor))
(defun (suit-colour s)
  (match s
    ((or Hearts Diamonds) ConsoleColor.Red)
    ((or Clubs Spades) ConsoleColor.Blue)))

(: print-card (-> Card void))
(defun (print-card c)
  (set-foreground! (suit-colour (record-ref c suit)))
  (console-write #"${c} ")
  (reset-colour!))

(defun (main)
  (vec-for-each print-card (vec-slice (shuffle (full-deck)) 0 5))
  (println "")
  0)

Each entry in import/extern specifies a local Bjolang identifier, the fully qualified CLR member, and its signature. For overloaded methods (such as Console.Write), the compiler determines which overload to bind based on the provided signature. Instance methods accept the target object as their first parameter. Properties and fields can be accessed via #:get and modified with #:set:

(import/extern
  (trim    (: System.String.Trim (-> string string)))
  (max-int (: System.Int32.MaxValue #:get)))

(trim "  hi  ")   ;; "hi"
max-int           ;; 2147483647

Avoid binding an external method to the name of an existing prelude function. Locally bound names and prelude symbols take precedence over import/extern declarations, which means an alias named println would simply be shadowed and ignored.

Classes: import/class

The import/class form maps a .NET class to a Bjolang type, exposing its constructor as a function suffixed with a dot (e.g. StringBuilder.). CLR enums map similarly, with individual members accessed using dot notation like Type.Member (seen with ConsoleColor.Red above).

(import/class
  (StringBuilder (: System.Text.StringBuilder (-> StringBuilder))))

(def sb (StringBuilder.))       ;; new StringBuilder()
(ignore (.Append sb "hello, "))
(ignore (.Append sb "world"))
(.ToString sb)                  ;; "hello, world"
(.-Length sb)                   ;; 12
(.ToUpper "shout")              ;; "SHOUT"

As shown in the example above, you do not need to declare every method up front with import/extern. Once an object’s type is known to the type checker, you can invoke any public method using (.Method obj args ...) and inspect properties using (.-Property obj). Because Append returns the builder itself and Bjolang enforces explicit handling of return values, discard unused returns intentionally using ignore.

When .NET fails

Idiomatic .NET code reports errors by throwing exceptions. Bjolang provides three distinct ways to translate these exceptions into safe value types:

(import/class
  (Uri (: System.Uri (-> string Uri)
          #:exceptions (System.UriFormatException))))

(import/extern
  (parse-int (: System.Int32.TryParse (-> string (out int) (Option int)))))

(match (Uri. "https://example.com/cards")
  ((Ok u) (.-Host u))                      ;; "example.com"
  ((Err e) (.-Message e)))

(parse-int "42")          ;; (Some 42)
(parse-int "forty-two")   ;; None

Async methods, and blocking calls

A .NET method returning a Task or Task<T> can be imported using the #:async modifier. Calling an async method from within a bjoroutine suspends the fiber until the task completes without blocking an OS thread, with no explicit await required:

(import/extern
  (read-text-async (: System.IO.File.ReadAllTextAsync (-> string string) #:async)))

(defbjo (main)
  (println (string-length (read-text-async "deck.bjo")))
  0)

Conversely, CLR methods known to perform synchronous, thread-blocking I/O should be tagged with #:blocking. Calling a blocking method from within a bjoroutine triggers a compiler warning; you can resolve this warning by offloading the call to a dedicated thread pool thread via (sync (blocking (fun () ...))).

Generics

Generic .NET types are imported by parameterizing them with type variables, and generic methods specify those type variables in their signature:

(import/class
  ((Dict %k %v) (: System.Collections.Generic.Dictionary)))

(import/extern
  (dict-try-get (: System.Collections.Generic.Dictionary.TryGetValue
                   (-> (Dict %k %v) %k (out %v) (Option %v)))))

At the boundary between systems, Bjolang types map cleanly to standard .NET equivalents: a Func<A, B> corresponds to (-> %a %b), IEnumerable<T> maps to (Seq %a), C# tuples map to Tuple, and native arrays T[] correspond to (Array %a). Note that constructing generic .NET classes dynamically and pattern matching on .NET objects via constructor patterns are not currently supported.

Culture and formatting

Bjolang programs execute using the invariant culture by default. For example, floating-point numbers format with a decimal point (2.5), avoiding regional discrepancies on machines where local culture settings would otherwise produce 2,5. If your application requires localized formatting, you can configure CultureInfo.CurrentCulture explicitly.

Calling Bjolang from C#

Every Bjolang module compiles to a public static class named <module>_Module, exposing top-level functions defined with defun as static methods and def values as static fields. Identifiers with characters invalid in C# are automatically mangled (for example, hyphenated names like full-deck become fullsubdeck), while collections remain Bjolang’s persistent data structures. You can inspect the emitted C# source code directly by passing the debug flag: bjo build -d, which generates out.cs.

Macros

Macros are functions executed by the compiler during the expansion phase. A macro receives an unevaluated syntax tree as input data and returns a new syntax tree that replaces the invocation form. In fact, many constructs in Bjolang that appear to be built-in language keywords—such as cond, when, type/derive, and with-test—are actually implemented as standard library macros.

def/macro

Macros are defined using def/macro. A macro transformer accepts three parameters: form, inject, and compare. Most macros are written using pattern matching via syntax-match from the (std syntax-match) library:

(import (std syntax-match))

;; (with-each-card (c deck) body ...) runs the body once for every card.
(def/macro (with-each-card form inject compare)
  (syntax-match form
    ((_ (name deck) body ...)
     #'(vec-for-each (fun (,name) ,@body) ,deck))
    (bad (syntax-error bad "(with-each-card (name deck) body ...)"))))

The syntax-match form decomposes syntax by structural shape. A wild-card pattern _ matches the macro name itself, variable names bind matched syntax fragments, and ellipsis ... matches repeated sequences. Templates are constructed using syntax quotation #'(...), where comma ,x unquotes an individual syntax value and ,@xs splices a list of syntax objects into the form. Malformed invocations can be rejected at compile time using syntax-error, which highlights the offending source location.

Pattern fragments like ((name value) ...) match lists of pairs and bind name and value to lists of syntax elements. Quoted symbols such as 'else match that literal identifier.

Because macro transformers are compiled code executed directly by the compiler while reading subsequent source files, a macro cannot be used in the same module where it is defined:

'twice' is a macro defined in this module, and a macro cannot be used where it is defined,
at bad2.bjo:5. Its transformer runs inside the compiler, so it has to be compiled before
whatever uses it is read — which cannot be true of the file it is written in. Move it to a
module of its own and import that. An (include ...) will not do: an included file becomes
part of this one.

For our card game, we place macro definitions in a dedicated module, cardmacros.bjo. Importing that module with (import "cardmacros.bjo") automatically makes its macros available. Unlike functions, macros do not require an explicit export list—every macro defined in a module is exported automatically.

The input passed to a macro is a value of type Syntax, a union type with variants like (SSym s) for symbols, (SInt text) for numbers, and (SList items) for parenthesized forms. Because macros are ordinary Bjolang functions, you can invoke helper functions, inspect Syntax records directly, and use recursion.

Hygiene and inject

Bjolang macros are hygienic by default. Identifiers introduced within a syntax template are automatically renamed to ensure they never collide with local bindings in the calling scope:

;; (or-else a b) is a, unless a is 0.
(def/macro (or-else form inject compare)
  (syntax-match form
    ((_ a b) #'(let ((tmp ,a)) (if (= tmp 0) ,b tmp)))))

;; in another module
(def tmp 9)
(or-else 0 tmp)    ;; 9, not 0

Here, the template’s temporary variable tmp and the caller’s outer variable tmp are treated as two distinct symbols, allowing the expression to evaluate correctly to 9. Furthermore, free identifiers referenced in the template (such as let, if, and =) always resolve to their prelude definitions, even if the calling scope has shadowed those names locally.

If a macro intentionally needs to introduce an unhygienic binding into the caller’s environment, it can explicitly opt out of renaming using (inject 'name):

;; (aif test then else): in `then`, `it` is what was inside the Some.
(def/macro (aif form inject compare)
  (syntax-match form
    ((_ test then else)
     #'(match ,test
         ((Some ,(inject 'it)) ,then)
         (None ,else)))))

(aif (map-try-ref #map(("a" 1)) "a")
     (println #"found ${it}")
     (println "nothing"))

Splicing multiple definitions: begin

A macro returns a single syntax form. If you need a macro to expand into multiple definitions or expressions at the top level, return a (begin ...) form, whose child elements are spliced directly into the enclosing scope:

;; (def/counter name) defines a function that counts how often it is called.
(def/macro (def/counter form inject compare)
  (syntax-match form
    ((_ name)
     #'(begin
         (def/mutable count 0)
         (: ,name (-> int))
         (defun (,name)
           (set! count (+ count 1))
           count)))))

;; in another module
(def/counter next-id)
(next-id)   ;; 1
(next-id)   ;; 2

In this expansion, the internal counter variable count is generated hygienically and cannot collide with any count variable in the importing module, while next-id takes on the identifier provided by the caller.

Pattern macros: def/pattern

The def/pattern form defines macros intended for use within pattern-matching positions rather than ordinary expressions. Nullary pattern macros can be referenced simply by their bare capitalized name:

;; Face matches a jack, a queen or a king.
(def/pattern (Face form inject compare)
  (syntax-match form
    (_ #'(or Jack Queen King))))

;; in another module
(: court? (-> Card bool))
(defun (court? c)
  (match (record-ref c rank)
    (Face #t)
    (_ #f)))

Pattern macros expand before the compiler performs exhaustiveness checking, meaning the compiler inspects the expanded or pattern just as if it had been written by hand.

Hash macros: def/hash-extend

Literal prefixes like #map(...) are reader-level hash macros. You can register custom hash literals using def/hash-extend. For example, we can implement a custom literal syntax for playing cards, converting ranks and suits into their typed constructors:

(import (std syntax-match) "deck.bjo")

(re-export Card Rank Suit)

;; A number is a Pip, a letter a court card.
(: rank-syntax (-> Syntax Syntax))
(defun (rank-syntax r)
  (syntax-match r
    ('J #'Jack) ('Q #'Queen) ('K #'King) ('A #'Ace)
    (n (match n
         ((SInt _) #'(Pip ,n))
         (_ (syntax-error n "a rank is 2 to 10, J, Q, K or A"))))))

(: suit-syntax (-> Syntax Syntax))
(defun (suit-syntax s)
  (syntax-match s
    ('clubs #'Clubs) ('diamonds #'Diamonds) ('hearts #'Hearts) ('spades #'Spades)
    (bad (syntax-error bad "a suit is clubs, diamonds, hearts or spades"))))

;; #card(Q hearts) is (Card (rank Queen) (suit Hearts)).
(def/hash-extend (card form inject compare)
  (syntax-match form
    ((_ r s) #'(Card (rank ,(rank-syntax r)) (suit ,(suit-syntax s))))
    (bad (syntax-error bad "#card takes a rank and a suit: #card(Q hearts)"))))
(import "deck.bjo" "cardmacros.bjo")

(defun (main)
  (println #card(Q hearts))                 ;; Q♥
  (println #card[10 spades])                ;; 10♠, either bracket works
  (println (court? #card(K clubs)))         ;; True
  (with-each-card (c [#card(A spades) #card(2 hearts)])
    (println #"a card: ${c}"))
  0)

Invalid literal syntax is flagged with a compile-time error at the point of use:

The hash macro 'card' failed at bad.bjo:3: a suit is clubs, diamonds, hearts or spades — in hurts

Notice the re-export in cardmacros.bjo: because the macro expansion emits constructor symbols like Card and Queen, those types must be resolvable in the module invoking the macro. Re-exporting them ensures that importing cardmacros.bjo alone brings the required types into scope. Importing deck.bjo alongside it remains recommended so that the corresponding ->str implementations are also present.

Literals that elaborate into your unions

Many embedded domain-specific languages do not require macros at all. In contexts where the type checker expects a specific union type, quoted lists can elaborate directly into union cases. Union cases specify their literal keyword tag using #:tag:

(type (: Action (Union (: Play int #:tag play)
                       (: Say string #:tag say)
                       (: Pass #:tag pass))))

(: script (List Action))
(def script '((play 3) (say "your turn") pass (play 1)))
;; = (list (Play 3) (Say "your turn") Pass (Play 1))

(def n 7)
(def (: more (List Action)) '((play ,n) pass))

Because elaboration is handled by the type checker rather than a syntactic macro, misspelled tags or invalid argument counts produce standard type errors:

Type Error at elab2.bjo:5: `sya` is not a tag of the union elab2/Action and no case of it
carries a list, so this literal cannot be one. Its tags are play, say or pass.

This elaboration mechanism only applies in positions where the type checker already knows that a specific union type is expected (which is why script and more include explicit type signatures). Standard library modules like (std run) and (std fmt) use this exact technique for command pipelines and text layouts.

Macro limitations

When designing macros, keep the following constraints in mind:

Testing and the REPL

Tests with (std simpletest)

Bjolang includes a lightweight test harness in (std simpletest). Its core assertions are straightforward: (expect label actual wanted) checks equality between values, (expect-true label ok) asserts a boolean condition, and (fail label) explicitly flags an unreachable branch. Passing checks output ok: label, while test failures emit descriptive FAILURE: diagnostics:

;; tests/deck-test.bjo
(import (std simpletest) (cardlib deck))

(defun (main)
  (expect "a deck has 52 cards" (vec-length (full-deck)) 52)
  (expect "an ace is worth 11" (card-points (Card (rank Ace) (suit Spades))) 11)
  (expect-true "shuffling keeps the cards" (= (vec-length (shuffle (full-deck))) 52))
  0)
$ bjo run tests/deck-test.bjo
ok: a deck has 52 cards
ok: an ace is worth 11
ok: shuffling keeps the cards

Whenever an assertion fails, the output reports both the computed value and the expected value: FAILURE: deliberately wrong gave 2 but wanted 3.

For asynchronous or resource-intensive testing, (with-test "name" body ...) executes the test body within an isolated structured concurrency scope configured with a default deadline. Any background fibers spawned during the test are automatically awaited, and the test fails if unclosed file ports remain open when the scope exits. Because with-test forms a concurrency scope, it must be invoked from within a bjoroutine.

You can also mock filesystem interactions using (with-fake-fs ((path contents) ...) body ...), which redirects file operations to an in-memory map for the duration of the enclosed block. Here is how we can verify our earlier high-score tracking functions without touching real files on disk:

(import (std simpletest) (std effect) "scores.bjo")

(defbjo (main)
  (with-test "no file means no scores"
    (with-fake-fs ()
      (expect "empty" (read-scores) [])))

  (with-test "a bad line is skipped"
    (with-fake-fs (("scores.txt" "ada 31\nnonsense\nbo 27\n"))
      (expect "two scores" (read-scores) [(Tuple "ada" 31) (Tuple "bo" 27)])))

  (with-test "a saved score reads back"
    (with-fake-fs (("scores.txt" "ada 31\n"))
      (save-score "cy" 12)
      (expect "appended" (vec-length (read-scores)) 2)))
  0)

Because with-fake-fs is implemented via algebraic effects, test modules using it must also import (std effect). In standard project layouts, test files live in tests/ and can be executed individually using bjo run tests/<test-name>.bjo.

The REPL

You can launch an interactive Read-Eval-Print Loop by running bjo repl. When invoked inside a project directory, the REPL automatically discovers and allows importing any of the project’s local modules. The REPL delegates terminal line editing to rlwrap if it is present on your system. Submitting an expression evaluates it and prints the result, while entering definitions compiles them and prints the bound names:

bjo> (+ 1 2)
3
bjo> (import "deck.bjo")
bjo> (vec-slice (full-deck) 0 3)
[2♣ 3♣ 4♣]
bjo> (defun (double (: x int)) : int (* x 2))
double
bjo> (double 21)
42

Unlike many Lisp REPLs, Bjolang’s REPL does not interpret forms. Instead, each entered interaction is compiled on the fly into an in-memory assembly using the exact same compiler pipeline applied to files on disk, ensuring identical semantics between interactive sessions and compiled production builds.

Top-level functions declared in the REPL require explicit type signatures, mirroring the rules for modules. You can supply parameter and return types inline directly on the defun, or submit a standalone signature on its own line immediately preceding the definition:

bjo> (: triple (-> int int))
bjo> (defun (triple x) (* x 3))
triple

Redefining a symbol shadows the previous definition for subsequent interactions; however, any existing code already compiled against the earlier version continues to invoke the original binding:

bjo> (defun (f (: x int)) : int (+ x 1))
f
bjo> (defun (g (: x int)) : int (f (f x)))
g
bjo> (defun (f (: x int)) : int (* x 100))
  note: f shadows the one from entry 1. Anything already compiled against that one still calls it.
f
bjo> (g 10)
12

The same incremental compilation model applies to trait implementations (impl): an implementation entered at the prompt is visible to subsequent entries, but cannot retroactively modify calls compiled earlier. Type :help to view available REPL commands, or press Ctrl-D (or type :quit) to exit.

Part III — The standard library

A short tour of the standard library. Each module’s full reference is its own page under Modules; the prelude’s is still Docs/prelude.org.

std/fmt

Formatting in Bjolang is divided into two layers: interpolation and layout. Interpolation provides a simple way to combine mixed scalar types into strings or print them directly using println* and str*. The layout layer provides a composable block algebra for more complex text formatting, such as padding, aligning, wrapping paragraphs, and laying out tables. For more details, see (std fmt).

std/rx

Bjolang provides a built-in regular expression engine. Unlike languages that use strings for regex (like "[a-z]+"), Bjolang uses structural S-expressions. This means you never have to double-escape characters, and the compiler validates your patterns at compile time. Built-in character sets and anchors are written as keywords (e.g., :bos, :digit), and operations that group or repeat patterns are function-like lists (e.g., (seq ...)). For more details, see (std rx).

std/http

The std/http module allows you to build and send HTTP requests. Sending a request three ways — blocking, suspending, or as an event — is the only thing that differs between the entry points. There is no C# in this module: System.Net.Http is reached with import/extern, and the response body is an ordinary TextInputPort. For more details, see (std http).

std/run

The std/run module provides a way to run external programs. A form is one thing to run, written as a quoted literal, like '(cat "hej.txt"). You can connect programs in a pipe with '(pipe (cat "a") (wc -l)), and redirect input and output to files. Redirections can nest, and you can even splice computed arguments or write stages in Bjolang. For more details, see (std run).

std/random

The random module provides a ChaCha20 generator, seeded with 256 bits from the OS CSPRNG, with one generator per thread. The default path locks nothing and shares nothing, making it safe and fast on a bjoroutine. The ambient source has no state to race on, so drawing from multiple threads is efficient. You can generate random integers and more. For more details, see (std random).

std/datetime

This module provides types and functions for handling time. The clock is an argument (with a default that a program can rebind), and the types refuse operations that mean nothing, such as adding a calendar day to an instant. The module separates Date (a calendar date without a timezone), Time (a time of day without a timezone), and DateTime (a date and time without a timezone), as well as handling timezones. For more details, see (std datetime).

text/bjodat

bjodat is Bjolang’s shapes read as data: no evaluation, no macro expansion, no environment. It is what a package manifest, a lockfile, or a config file is written in. You can parse text straight into a record using bjodat-parse-<Name>, which avoids intermediate trees and allocations. For more details, see (text bjodat).

text/json

The text/json module provides JSON parsing and generation capabilities. Like bjodat, it allows for working with structured data but using the standard JSON format, which is essential for web communication. For more details, see prelude.org and related standard library documentation.

Appendices

Appendix A — Coming from Scheme or ML

Bjolang sits at the crossroads of two major functional programming lineages. From the Scheme and Lisp tradition, it draws its uniform S-expression syntax, prefix notation, code-as-data perspective, and hygienic macro system. From the ML family—specifically OCaml and F#—it inherits static Hindley-Milner type inference, algebraic data types, compile-time exhaustiveness checking, traits, and high-performance immutable data structures.

Depending on which background you bring to Bjolang, parts of the language will feel like second nature while other parts will require shifting your mental habits.

Coming from Scheme

If you have written Scheme, Racket, or Common Lisp, the visual rhythm of Bjolang will feel immediately comfortable:

However, several fundamental differences will quickly become apparent:

Coming from F# or OCaml

If you come from F#, OCaml, or Haskell, the conceptual model of Bjolang’s type system and runtime behavior will feel very natural:

The main shifts you will encounter coming from ML are:

Appendix B — Reading compiler errors

Bjolang’s frontend is designed to catch structural, typing, and concurrency errors early, providing descriptive diagnostics that point directly to the source of the problem before code reaches the C# compiler. Below are some of the most common compiler messages you will encounter, what causes them, and how to resolve them.

1. Missing top-level type signature

(defun (greet name)
  (println (str "Hello, " name)))

When compiled, the frontend rejects this with:

Type Error: Function 'greet' requires a type signature (: greet ...) at hello.bjo:1:1

Why it happens: Unlike local helper functions defined inside another function, every top-level definition in a module must carry an explicit type signature. The compiler requires this so that module interfaces are self-documenting and can be emitted into the compiled assembly’s public metadata without requiring whole-program inference across files. The only exception is main, which has a fixed signature known to the compiler.

How to fix it: Add an explicit signature right before the definition:

(: greet (-> string unit))
(defun (greet name)
  (println (str "Hello, " name)))

2. Calling a bjoroutine from a synchronous function

(: fetch-data (-> string string))
(defbjo (fetch-data url)
  (http-get url))

(: process-request (-> string string))
(defun (process-request url)
  (fetch-data url))

The compiler detects that a synchronous function is attempting to suspend:

Type Error at server.bjo:7:10: calling 'fetch-data' is a yield point, and a yield point is not allowed here. 'process-request' is defined with (defun ...). Define it with (defbjo ...).

Why it happens: In Bjolang, calls to bjoroutines look identical to ordinary function calls—there is no explicit await keyword. However, a bjoroutine can yield or suspend its fiber. A function defined with defun compiles to a regular synchronous C# method and cannot suspend. Allowing a synchronous method to call a suspending bjoroutine directly would require blocking the thread or breaking call-stack invariants.

How to fix it: If the caller needs to perform suspending operations, define it using defbjo instead of defun:

(: process-request (-> string string))
(defbjo (process-request url)
  (fetch-data url))

If the caller must remain synchronous, spawn the bjoroutine inside an isolated scope or handle it asynchronously using the concurrency runtime.

3. Mismatch between signature and argument count

(: render-card (-> Card Suit string))
(defun (render-card card)
  (card->string card))

The compiler flags the arity divergence immediately:

Type Error: Function 'render-card' has 1 mandatory args but signature specifies 2 at cards.bjo:2:1

Why it happens: The parameter list in the defun head does not match the arrow type declared in the preceding (: ...) form. Here, the signature promised two mandatory arguments (Card and Suit), but the definition provided only one parameter (card).

How to fix it: Align the parameter list with the signature, either by updating the type signature or by adding the missing parameters to the function head.

4. Non-exhaustive pattern matching

(type (: Suit (Union Clubs Diamonds Hearts Spades)))

(: suit-color (-> Suit string))
(defun (suit-color s)
  (match s
    (Hearts "red")
    (Diamonds "red")))

The exhaustiveness checker discovers the missing cases:

Pattern Error at game.bjo:6:3: this match does not cover every value. Clubs reaches no clause.

Why it happens: Bjolang verifies that pattern matches cover every possible variant of a union or structure. If an unhandled case is matched at runtime, there would be no clause to execute. The error message includes a concrete witness (such as Clubs) showing an unhandled value that would fail to match.

How to fix it: Add the missing cases to the match expression, or provide a wildcard fallback _ if other cases should receive default handling:

(: suit-color (-> Suit string))
(defun (suit-color s)
  (match s
    (Hearts "red")
    (Diamonds "red")
    ((or Clubs Spades) "black")))

5. Exporting a binding that references a private type

Suppose you define a module player.bjo where a type is private, but an exported function returns it:

(export make-player)

(type (: Player (Record (: name string) (: score int))))

(: make-player (-> string Player))
(defun (make-player name)
  (Player name 0))

Compiling player.bjo fails with:

Export Error: the exported binding 'make-player' names the type 'Player', which this module declares and does not export. A type crosses a module boundary only when it is named in an (export ...), so an importer has no way to resolve this. Write (export Player), or (export Player) with the declaration marked #:opaque to keep its representation to this module's code.

Why it happens: If another file imports make-player, the calling module will receive a value of type Player. But because Player was not exported, the importing module cannot name the type in signatures or inspect its fields.

How to fix it: Export the type in the module header:

(export Player make-player)

If you want to keep the internal fields and constructor private to player.bjo, mark the type as #:opaque:

(export Player make-player)

(type #:opaque (: Player (Record (: name string) (: score int))))

6. Accessing fields of an opaque type

Following from the previous example, suppose an importing module attempts to read a field from an opaque record:

(import "player.bjo")

(defun (main args)
  (let ((p (make-player "Alice")))
    (println (int->string (record-ref p score))))
  0)

The compiler prevents the private access:

Type Error at main.bjo:5:37: 'score' cannot be read here. player/Player is exported #:opaque.

Why it happens: When a type is exported with #:opaque, its record layout and constructor are visible only to the module that defined it. Outside modules may pass values of the type around, but they cannot directly read or write fields with record-ref or pattern match on internal components.

How to fix it: Expose a public accessor function in the defining module (e.g. player-score) and export that function alongside the type.

7. Using a macro where it is defined

(import (std prelude))
(import (std syntax-match))

(def/macro (def/thunk form inject compare)
  (syntax-match form
    ((_ name body)
     #'(defun (,name) ,body))))

(def/thunk answer 42)

When compiled, the macro expander reports:

'def/thunk' is a macro defined in this module, and a macro cannot be used where it is defined, at thunk.bjo:9:1. Its transformer runs inside the compiler, so it has to be compiled before whatever uses it is read — which cannot be true of the file it is written in. Move it to a module of its own and import that. An (include ...) will not do: an included file becomes part of this one.

Why it happens: Bjolang macros are not textual string replacements. A macro transformer is written in Bjolang, compiled into a .NET assembly, and executed dynamically inside the compiler process to expand syntax trees. Because the macro must already be compiled before the compiler can read and expand code using it, a file cannot use macros defined within its own source text.

How to fix it: Move your macro definitions into a dedicated module (such as macros.bjo), and then import that module into the files where you wish to use them:

;; In main.bjo:
(import "macros.bjo")

(def/thunk answer 42)

Appendix C — How it compiles

Bjolang targets the .NET 10 CLR by compiling to readable, high-performance C# 12 code, which is then compiled into managed assemblies by the Roslyn compiler. Rather than running an interpreter or emitting raw IL bytes, compiling through C# allows Bjolang to leverage RyuJIT’s advanced optimization pipeline, benefit from modern runtime features like value types and spans, and interoperate seamlessly with the broader .NET ecosystem.

For the curious engineer, this appendix explains what happens under the hood when you build a Bjolang program.

The compilation pipeline

When you run bjo build or bjo run, the compiler processes source files through several distinct phases:

The C# output model and runtime assemblies

Each Bjolang source file compiles to a static C# class named after the module: deck.bjo becomes deck_Module.

Because Bjolang allows characters in identifiers that are invalid in C# (such as hyphens, exclamation marks, and question marks), the code generator applies deterministic name mangling:

Every compiled program links against a small set of optimized runtime assemblies:

AssemblyRole in compiled programs
BjolangRuntimeCore primitives, string formatting, dynamic environment, and conversions
CollectionsPersistent (Vec %a) based on Relaxed Radix Balanced (RRB) trees
SchemeListPersistent singly-linked (List %a) with Cons and Nil
MapPersistent (Map %k %v) based on Compressed Hash-Array Mapped Prefix-trees (CHAMP)
BjoSetPersistent hash set (Set %a)
BjoOrderedMapBalanced ordered map (OrderedMap %k %v)
BjoOrderedSetBalanced ordered set (OrderedSet %a)
BjomlConcurrency runtime: lightweight fibers, channels, promises, and CML scheduler

These assemblies reside once in the Bjolang installation directory. A compiled program does not copy these DLLs next to its binary; instead, it registers a custom assembly resolver on startup that points directly back to the runtime installation directory.

Colour twins: solving the function colouring problem

In languages with asynchronous functions (such as JavaScript, Python, C#, or Rust), functions are typically divided into two “colours”: synchronous functions that return T, and asynchronous functions that return Task<T> or Promise<T>. This dichotomy often splits standard libraries in two: an author must write map for synchronous callbacks, and a separate mapAsync for asynchronous callbacks.

Bjolang solves this problem at the compiler level using colour twins:

1. The polymorphic arrow -?->: When writing a higher-order function that accepts a callback, you can declare its parameter with -?-> rather than a fixed arrow:

(: list-map (-> (List %a) (-?-> %a %b) (List %b)))

2. Source-level twin generation: Before type checking occurs, the ColourTwins pass scans all declarations. When it detects a function with a -?-> parameter, it automatically generates a second definition from the same source body, repainted with suspending signatures. Both definitions are checked independently. This ensures that effects and await points are fully verified without needing error-prone AST copying after inference.

3. Reaching twins: Any function defined with defun that calls a dual-mode function (such as defbjouble primitives that provide both synchronous and fiber implementations) automatically receives a suspending twin as well. If an author explicitly wants a function to remain strictly synchronous, they can mark it with #:sync.

4. Automatic call-site selection: When a function is called, the compiler checks the colour of the caller. If an ordinary defun calls list-map, it calls the synchronous twin. If a bjoroutine (defbjo) calls list-map with a suspending callback, it automatically links to the suspending twin. The author writes the higher-order function once, and the language transparently provides both versions.

Monomorphisation and trait inlining

Generic code bounded by traits (such as (where (Eq %a))) is initially desugared using dictionary passing:

(: least (-> %a %a %a) (where (Ord %a)))
(defun (least a b)
  (if (<= a b) a b))

Under standard dictionary passing, <= is compiled into an interface method call: _dict_Ord_a.le(a, b). While flexible, virtual calls through interface dictionaries carry notable overhead: they prevent inlining, require heap-allocated dictionary references, and force value types (like int or structs) to be boxed.

To eliminate this cost, Bjolang employs monomorphisation:

As a result, generic functional abstractions in Bjolang compile down to the same tight loops and direct machine instructions as hand-written C#.

Incremental builds and .bjobuild

To keep development fast, Bjolang includes an incremental compilation system designed for sub-50ms turnarounds.

When a compilation finishes successfully, the compiler writes a build manifest named <source>.bjobuild beside the source file:

mode release
output /home/linus/game.exe
compiler /home/linus/bjolang/bin/Release/net10.0/Bjolang.dll
source /home/linus/game.bjo
source /home/linus/deck.bjo
dep /home/linus/bjolang/lib/std/prelude.dll
dep /home/linus/bjolang/BjolangRuntime/bin/Release/net10.0/BjolangRuntime.dll

When you run bjo run game.bjo again:

You can pass -c to force an unconditional rebuild, or -d to create an unoptimized debug build with AST and generated C# dumps.

Appendix D — Editor support

Editing Bjolang code is most pleasant in Emacs, where specialized major modes support both Bjolang source files and Samizdat documentation.

Bjolang mode: bjomode.el

bjomode.el is located in the root of the Bjolang repository. It provides syntax highlighting, indentation, and structural navigation tailored to Bjolang’s syntax.

To use it in your Emacs configuration, add the repository directory to your load-path and require the mode:

(add-to-list 'load-path "/path/to/bjolang")
(require 'bjo-mode)

bjo-mode associates automatically with *.bjo and *.protobjo files.

Key capabilities of the mode include:

Samizdat mode: samizdat-mode.el

All documentation for Bjolang—including this manual—is written in Samizdat (.sz). The major mode samizdat-mode.el provides syntax highlighting and editing commands for Samizdat documents.

To configure it:

(add-to-list 'load-path "/path/to/samizdat")
(require 'samizdat-mode)
(add-to-list 'auto-mode-alist '("\\.sz\\'" . samizdat-mode))

Key editing features and keybindings include:

std/random

Random numbers: ChaCha20, one generator per thread, and sources of your own.

(import (std random))

(random-int 1 6)                          ; the calling thread's generator
(random-int 1 6 #:rng my-source)          ; one of your own
(with-random-seed 42UL (deal-a-hand))     ; a run that replays

The generator is ChaCha20, seeded with 256 bits from the operating system’s CSPRNG, one per thread. The default path locks nothing and shares nothing, so drawing from two threads at once costs what drawing from one costs. A source you make yourself is locked instead, once per operation.

Every function takes #:rng, which defaults to (parameter-ref current-random). A program that wants another source binds the parameter and changes no call site.

The ambient source. The value of current-random holds no generator. It names the calling thread’s, and which thread that is is decided at each draw. That is what makes the default safe on a bjoroutine: a fiber may resume on a thread it did not suspend on, and a source read before the suspension would otherwise go on drawing from a generator another fiber is already inside. The ambient source has no state to race on.

A bound source is safe to share, and still does not replay. Every operation on a source you made takes its lock, so no draw is lost, doubled or torn however fibers interleave. What the lock cannot give is what the seed was for: the stream is one sequence, but which fiber gets which value of it is the scheduler’s choice.

(parameterize ((current-random (make-random/seed64 42UL)))
  (bjo (worker))     ; these two share one generator, under its lock:
  (bjo (worker)))    ; safe, but the order they draw in is not fixed

Give each fiber (random-split (parameter-ref current-random)), or a seed of its own, and the run replays. The default source needs neither: it is one generator per thread, reached by no other.

The lock is per operation, not per draw. shuffle-array! and random-bytes! are many draws and take the lock once, so a shuffle is the permutation the stream describes rather than one interleaved with another fiber’s. It also means that shuffling a large array under a source other fibers are drawing from makes them wait.

Binding a seeded source makes everything under it predictable, including code you did not have in mind: a session token minted inside a with-random-seed is guessable. Keep the extent to what is being replayed.

Seeded means reproducible, not unguessable. make-random/seed64 and make-random/seed256 take what they are given. Use make-random, or the default, for anything anyone else must not predict.

See also: random-int, current-random, make-random, with-random-seed