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.
# 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
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.
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 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:
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):
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:
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:
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
A block’s value is its last expression:
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:
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.
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 int → float.
Convert explicitly with the builtins:
| builtin | type |
|---|---|
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, 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.
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:
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:
type widget =
name: str
price: float
You can ascribe a type to any parenthesized expression: (e : ty).
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:
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.
[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):
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:
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 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.
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:
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] = ().
A sum type is a set of |-separated variants, each with an optional
payload using the same field syntax as records:
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:
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)?”
else:.circle(r)); _ skips a field.else:.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):
let primes: [int] = []
for p in primes:
print(p)
else:
print("no primes")
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:
a + b and a+b are both fine; a +b and a+ b are
errors.1 + -2 and f(-1) are fine; 1+-2 is an error.Together these keep negation unambiguous: x <- 1 (or x<-1) assigns,
x < -1 compares.
_ separators. Floats need digits on both
sides of the dot: 1.0, not 1..\n \t \" \\ escapes, single-line
only.' — the ML habit of x' works.An honest list, while things are young:
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.