The Soma language

Soma is a strongly, statically typed, expression-oriented language. The syntax is F#-flavoured ML with some Python habits: significant indentation, blocks introduced with :, # comments, and and/or/not spelled as words.

It is young — what runs today is a tree-walking interpreter (compilers come later), and everything here is subject to change. Soma is a working title. You can try everything on this page in the playground.

A taste

run ▶
# Comments start with '#'.

fn fib(n: int) -> int:
    if n < 2:
        n
    else:
        fib(n - 1) + fib(n - 2)

let mutable i = 0
while i <= 10:
    print(fib(i))
    i <- i + 1

Bindings

Values are introduced with let, and types are inferred unless you annotate. Bindings are immutable by default; let mutable opts in, and assignment uses <-, which only works on mutable bindings — the type checker enforces both.

run ▶
let x = 42                 # type inferred: int
let y: float = 1.5         # optional annotation, checked
let mutable count = 0
count <- count + 1

Shadowing is allowed, ML-style: let x = x + 1 creates a new x.

Functions

Functions are introduced with fn. Parameters are a parenthesized, comma-separated list with type annotations; the return type is inferred unless annotated with ->. The body follows : — inline on the same line, or an indented block:

run ▶
fn add(a: int, b: int): a + b          # return type inferred: int

fn fib(n: int) -> int:                 # explicit return type
    if n < 2: n
    else: fib(n - 1) + fib(n - 2)

One wrinkle: recursive functions need an explicit return type — while the body is still being inferred there is nothing to assume for the recursive call. The checker reminds you if you forget.

Arguments can be passed by name, in any order after the positional ones, and trailing parameters can have defaults (evaluated at call time):

run ▶
fn area(w: float, h: float = 1.0): w * h
print(area(3.0))              # 3.0
print(area(h = 4.0, w = 3.0)) # 12.0

Anonymous functions are the named form minus the name — same parens, same colon-body rule:

run ▶
let double = fn(n: int): n * 2
fn twice(f: (:int) -> int, x: int): f(f(x))
print(twice(double, 10))      # 40

Functions are first-class values and close over their environment, including mutable bindings:

run ▶
fn make_counter():
    let mutable count = 0
    fn next() -> int:
        count <- count + 1
        count
    next

let tick = make_counter()
print(tick())    # 1
print(tick())    # 2

Everything is an expression

A block’s value is its last expression:

run ▶
let x =
    let a = 10
    let b = 20
    a * b          # x = 200

if is an expression too. : introduces each body — an expression on the same line is an inline body; end of line means an indented block follows. There is no then and no elif; else followed directly by if chains without stacking indentation:

run ▶
let n = 7
let parity = if n % 2 == 0: "even" else: "odd"

let sign =
    if n < 0: "neg"
    else if n == 0: "zero"
    else: "pos"

Both branches must have the same type, and an if with no else must have type unit.

Values are not silently thrown away: discarding a non-unit value is an error. Write let _ = ... to discard one deliberately.

Types

Type names are lowercase: int (64-bit), float (64-bit), bool, str, and unit (written ()). Identifiers are case-sensitive, but case carries no meaning — capitalize your own names or don’t, as you like.

There are no implicit conversions, not even intfloat. Convert explicitly with the builtins:

builtintype
print(x)any → unit
str(x)any non-function → str
int(x)float → int (truncates)
float(x)int → float

Integer overflow and division by zero are runtime errors, not wraparound.

Tuples and records

Tuples, records, and parameter lists are all one thing in Soma: a product type, an ordered set of fields. A field is [name] [: type] [= value] — a bare identifier is always a name, and a type only ever appears after a :. Fields without a name start with the colon: (:int, :str) is the type of an anonymous int/str pair.

run ▶
let pair = (1, "two")            # a tuple
let one = pair.0                 # positional projection
let (a, b) = pair                # destructuring

let p = (x = 1.0, y = 2.0)       # named fields (bind with '=')
print(p.x)

(5) is just 5 — grouping. A 1-tuple takes a trailing comma, Python style: (5,). A lone named field does too: (x = 1,).

Named types are declared with type, which always mints a new, distinct type:

run ▶
type meters = float                  # a newtype: same operations as
let m = meters(5.0)                  # float, but never mixes with it;
let f = float(m)                     # convert explicitly

type point = (x: float, y: float)    # a record
let p = point(x = 1.0, y = 2.0)      # construct by name or position

type alias distance = meters         # transparent alias, no new type

meters + meters works; meters + float is an error. Types are nominal: two distinct declared types never mix, even if they look identical.

Records can also be declared multiline:

run ▶
type widget =
    name: str
    price: float

You can ascribe a type to any parenthesized expression: (e : ty).

Constructors

Every type has exactly one constructor. If you don’t write one, you get the memberwise default seen above — fields by position or name. To write your own, add a with impl: block directly after the declaration; the unnamed fn (params) -> Self: is the constructor, and .field = value initializes a field:

run ▶
type circle =
    radius: float
    area: float

with impl:
    fn (r: float) -> Self:
        .radius = r
        .area = 3.14159 * .radius * .radius

let c = circle(2.0)

Once initialized, a field can be read (.radius above). The checker makes sure every field is initialized exactly once on every path before the body ends.

Writing anything in an impl block — a constructor or, later, methods — replaces the memberwise default. If you want a custom impl and the memberwise constructor, opt back in with @auto_new on the type.

Lists

[t] is the list type, [1, 2, 3] the literal. Elements are separated by commas or newlines. Indexing is bounds-checked, + concatenates, == compares by content, and .len is a property (no parens):

run ▶
let primes = [2, 3, 5, 7]
print(primes[0])
print(primes.len)
print([1, 2] + [3])        # [1, 2, 3]

An empty list needs a type from context: let xs: [int] = [].

In a multiline =-binding, if the first line is a bare value the whole body is a list literal — one element per line, no brackets needed:

run ▶
let primes =
    2
    3
    5

(If the first line is a statement, the body is an ordinary computed block instead. Data or code, decided by the first line.)

Dicts

Dicts use brackets too: [str: int] is a type, ["one": 1, "two": 2] a literal, and [:] the empty dict. The colon after the first element is what makes it a dict rather than a list.

run ▶
let scores = ["anna": 3, "ben": 5]
print(scores["anna"])
print(scores.len)

The bare multiline form works here too — a key: value first line commits to a dict:

run ▶
let scores =
    "anna": 3
    "ben": 5

In positions where the type is known (annotated lets, arguments, assignments…), () can stand in for an empty container: let xs: [int] = ().

Sum types and match

A sum type is a set of |-separated variants, each with an optional payload using the same field syntax as records:

run ▶
type shape =
    | circle(radius: float)
    | rect(w: float, h: float)
    | dot

let c = circle(radius = 1.0)      # payload variants are constructors
let d = dot                       # payload-less variants are values

type intlist = | nil | cons(head: int, tail: intlist)   # recursion works

match is an expression. Arms live in an indented block, each introduced by is; the default is an else: at the match’s own indentation — the same rule as if/else:

run ▶
fn area(s: shape) -> float:
    match s:
        is circle(r): 3.14159 * r * r
        is rect(w, h): w * h
        is dot: 0.0

fn describe(s: shape) -> str:
    match s:
        is dot: "a dot"
    else: "something with area"

Read an arm with the scrutinee: “s is circle(r)?”

Loops

while cond: and for x in xs: take the usual inline-or-block body and have type unit. Both accept an else: which runs iff the body never executed — the “it was empty” case (not Python’s no-break rule):

run ▶
let primes: [int] = []
for p in primes:
    print(p)
else:
    print("no primes")

Operators

Precedence, loosest to tightest:

or
and
not
==  !=  <  <=  >  >=      (no chaining; combine with 'and')
+  -                      ('+' also concatenates strings and lists)
*  /  %
unary -
f(args)

and/or short-circuit. Comparisons don’t chain — write 0 <= x and x < 10.

Spacing around operators is meaningful, and two simple rules cover it:

Together these keep negation unambiguous: x <- 1 (or x<-1) assigns, x < -1 compares.

Nuts and bolts

Rough edges today

An honest list, while things are young:

Where it’s going

Near-term: optionals (t?), spread (point(...p, x = 2.0)), sets, element assignment, and richer patterns. Further out: application by juxtaposition (f x), traits with generics and full type inference, user-defined operators, an effects system (fx beside fn), and — once the language settles — a bytecode VM, then JIT and AOT compilation. Soma is also the middle of a planned family: Ribo, a smaller purely declarative language, and Pheno, a full systems language.