A value receives a message. That's the whole language.
One construct: [ ] — object, block, list, callable.
: initializes. := mutates. _ is the implicit argument.
No keywords. No precedence. Cells all the way down.
A value receives a message by juxtaposition. Left to right. No precedence.
3 + 5 -- 8
xs "name" -- lookup
inc 4 -- call
3 + 5 * 2 -- 16 (not 13)
Object, block, list, callable. All the same thing.
[1, 2, 3] -- list
[_ + 1] -- function
[n | n * 2] -- named param
[] -- none
: creates a field (lazy). := updates an existing one.
x : 0 -- init
x := x + 1 -- mutate
x := x + 1 -- mutate again
Single-arg blocks use _. No naming ceremony.
double : [_ * 2]
square : [_ * _]
5 times [_ print]
Booleans select: true picks first, false picks second.
x > 0 ["positive", "negative"]
n % 2 = 0 ["even", "odd"]
: runs once. := runs every call. State persists.
counter :
x : 0
x := x + 1
counter -- 1
counter -- 2
counter -- 3
Named methods are fields on the cell.
account :
balance : 0
deposit amt :
balance := balance + amt
alice : "alice" account
alice "deposit" 50
Arguments deep-copied. Receivers aliased. No aliasing bugs.
-- receiver aliased (can mutate)
alice "deposit" 50
-- argument copied (safe)
f xs -- f gets a copy
[] inert until messaged. () runs immediately.
thunk : [heavy-computation]
-- runs only when messaged
result : (heavy-computation)
-- runs now
Numbers, strings, booleans have prototype cells. Operators are methods.
-- 3 + 5: 3 receives +
-- returns partial, receives 5
-- custom ops on your cells:
vector :
+ other : ...
name : val init ·
name := val mutate ·
[...] lazy cell ·
(...) eager ·
_ implicit arg ·
params | body named params ·
obj msg send ·
-- comment ·
* name shared
obj arg — apply is the only verb"Not bad for a language that fits on a napkin."
Design thesis: the semantics is the policy; the fast representation is an optimization; the trick is not always paying for it. Deep-copy, persistence, and immutability all have unconditional semantics but conditional cost — the compiler proves the cost away when it can, and you opt out with flags when it can't.
ephemeral opts out."Like a Tamagotchi you can't kill. SELECT * FROM your_program."immutable — deep freeze, enforced at runtime. Mutations panic. shared opts out of copy-on-pass. Flags live in slot 0 of arrays — they persist for free and survive copies."If you wanted things to change, you should have used JavaScript."Float64Array (packed, JIT-friendly). Bolt a symbolic key onto a list? It PROMOTES to a Hybrid (dense elements + side Map). Pay for the map only when you use it. Elements never leave the fast lane."A humble list discovers it has an inner object."add 3 returns a function."Every function is patient."memoize to any block. Free caching."Your function is now a lookup table."each/collect/fold work on anything."One loop to rule them all."xs i applies xs to i. A list returns its i-th element. A function returns its result. A map returns the value at key i. A boolean selects. One operation, every value answers it."We don't have indexing. We don't have calling. We have apply."Float64Array. Bolt a symbolic key? It promotes to a Hybrid (dense + side Map). Numeric-only code never pays. The compiler tracks which fields may promote and routes through CALL/STORE only when needed."A humble list discovers it has an inner object. We don't judge."immutable, shared, ephemeral, durable — trailing words on a binding. Flags live in slot 0 of arrays as a bit field. They ride inside the value, serialize with it, survive copies. No wrapper objects."Your metadata is in the array. It's been there the whole time."checkpoint saves everything. Your program's code and its state live in the same SQLite database."Is it a program or a database? Yes."ephemeral and it skips all persistence plumbing. Hot working arrays stay monomorphic and allocate directly. Durable arrays route through the loader, costing ~30%. The fast path is explicit."Some data deserves to die when the program stops. We respect that."not, and, or, max, min, abs, sum, filter — all written in Sprout itself. The standard library dogfoods the language. If you can't express it in Sprout, it shouldn't be in the prelude."The language ate its own dogfood and asked for seconds."immutable skips copy (can't change — share freely).shared opts out (you asked for aliasing, you got it).ephemeral strips persistence plumbing for hot arrays.Bind a name with : — that's it.
x : 42
name : "Sprout"
x print
-- x := x + 1 (mutate)
Blocks are closures in square brackets. _ is the implicit argument.
double : [_ * 2]
double 5 print
add : [a, b | a + b]
add 3 4 print
Everything is message-passing. Chain messages left to right.
inc : [_ + 1]
double : [_ * 2]
inc 4 print
double (inc 4) print
Booleans select from a pair: true picks the first, false the second.
max : [a, b | a > b [a, b]]
max 9 3 print
abs : [_ < 0 [0 - _, _]]
abs -7 print
Blocks can call themselves. Tail calls are optimized.
fib : [n |
n <= 1 [n,
(fib (n - 1)) + (fib (n - 2))
]
]
fib 10 print
Brackets with 2+ elements make a list. 1-based indexing.
nums : [10, 20, 30]
nums 1 print
nums length print
nums each [_ print]
Bind or rebind with name : value.
x : 10
x : x + 1
Arithmetic, comparison, and string concat.
+ - * / % -- math
= != > < >= <= -- compare
++ -- concat
Double-quoted, with escape sequences.
"hello" length print
"foo" ++ " bar" print
"abc" = "abc" print
Single-quoted text is a comment.
-- this is a comment
x : 42 -- inline too
while, times, each — all via blocks.
-- count to 5
5 times [_ print]
-- while loop
i : 0
while [i < 10] [i := i + 1]
i print
each, collect, fold — list operations.
xs : [1, 2, 3, 4, 5]
xs collect [_ * _] print
xs fold 0 [a, b | a + b] print
Compose, curry, and pass blocks around.
compose : [f, g | [f (g _)]]
inc : [_ + 1]
dbl : [_ * 2]
incdbl : compose dbl inc
incdbl 10 print
Mark a block pure or memoize for caching.
fib : [n |
n <= 1 [n,
(fib (n - 1)) + (fib (n - 2))
]
] memoize
fib 30 print
Sprout has no keywords for control flow, boolean logic, or pattern matching. These patterns emerge naturally from selection, closures, and message-passing.
Booleans are selectors — true picks the first element, false picks the second. Negation is just a reversed selection.
not : [_ [false, true]]
not true print
not false print
Multi-way dispatch via nested selection. Each branch is an = check whose false arm falls through to the next test.
describe : [n |
n = 0 ["zero",
n > 0 ["positive", "negative"]
]
]
describe 42 print
describe -1 print
Conditional value — returns the value when true, none otherwise.
when : [cond, val | cond [val, none]]
when true "yes!" print
when false "nope" print
id returns its argument unchanged. const ignores the second argument.
id : [_]
const : [a, b | a]
id 42 print
const "keep" "ignore" print
Thread a value through a function. Combine with fold to build pipelines.
pipe : [val, f | f val]
pipe 5 [_ * 2] print
'pipeline via fold'
transforms : [[_ + 1], [_ * 2], [_ - 3]]
result : transforms fold 10 [acc, f | f acc]
result print
Swap the argument order of a two-argument block.
flip : [f | [a, b | f b a]]
sub : [a, b | a - b]
rsub : flip sub
sub 10 3 print
rsub 10 3 print
Numbers built from pure functions. A church numeral n applies f to x n times. Addition is composition.
zero : [f | [_]]
succ : [n | [f | [x | f (n f x)]]]
one : succ zero
two : succ one
three : succ two
to-num : [cn | cn [_ + 1] 0]
to-num three print
'church addition'
add : [m, n | [f | [x | m f (n f x)]]]
to-num (add two three) print
Division that returns none on zero. then chains operations — if any step is none, the whole chain is none.
safe-div : [a, b |
b = 0 [none, a / b]
]
then : [val, f |
val = none [none, f val]
]
safe-div 10 2 print
safe-div 10 0 print
'chain: 100 / 5, then double'
then (safe-div 100 5) [_ * 2] print
then (safe-div 100 0) [_ * 2] print
The entire FizzBuzz program is a single expression. Nested selection eliminates all branching keywords.
20 times [
_ % 15 = 0 ["FizzBuzz",
_ % 3 = 0 ["Fizz",
_ % 5 = 0 ["Buzz", _]
]
] print
]
A test framework in 6 lines. assert compares got vs expected and tracks pass/fail counts.
pass : 0
fail : 0
assert : [name, got, expect |
got = expect [
(pass : pass + 1,
"ok " ++ name print),
(fail : fail + 1,
"FAIL " ++ name print)
]
]
assert "2+2" (2 + 2) 4
assert "max" (3 > 7 [3, 7]) 7
The three combinators that can express any computable function. S distributes, K discards, I is identity.
'I: identity'
I : [_]
'K: constant — return first, ignore second'
K : [a | [a]]
'S: distribute — S f g x = f x (g x)'
S : [f, g | [x | (f x) (g x)]]
'S K K = I (classic proof)'
skk : S K K
skk 42 print
x : 42const x = 42x = 42Why : not =? — = is the equality operator. Colon reads as "is" — x : 42 means "x is 42". One symbol, no const/let/var keyword needed. Assignment and definition are the same operation.
add : [a, b | a + b]const add = (a,b) => a+badd = lambda a,b: a+bWhy no function keyword? — Functions are just blocks assigned to names. There's no distinction between a "function definition" and a "value". A block [ ] is always a closure, always a first-class value. | separates parameters from body — reads like a fill-in-the-blank.
add 3 4add(3, 4)add(3, 4)Why no parentheses? — Everything is message-passing. add 3 sends 3 to add, which returns a partial. That partial receives 4. No special call syntax — juxtaposition IS the call. Currying is automatic.
42 printconsole.log(42)print(42)Why postfix? — print is a message sent to the value. You read left-to-right: "take 42, print it." Consistent with every other operation — 42 print, 42 sqrt, "hi" length. No special forms.
'this is a comment'// this is a comment# this is a commentWhy single quotes? — Double quotes are strings. Single quotes are "talking to yourself" — natural English quoting. Frees up // and # for potential future use. Comments are delimited, not line-based, so they work inline.
x > 0 [yes, no]x > 0 ? yes : noyes if x > 0 else noWhy no if/else? — Booleans are objects that select from a list. true picks element 1, false picks element 2. No special syntax needed — if is just what booleans do. The language has zero keywords for control flow.
while [i < 10] [i : i + 1]while (i < 10) { i++ }while i < 10: i += 1Why blocks for both condition and body? — while is a regular function that takes two blocks: one to evaluate as the condition, one to run as the body. Both are lazy (blocks aren't evaluated until called). No special syntax — loops are library code.
5 times [_ print]for (let i=1; i<=5; i++) ...for i in range(1,6): ...Why 5 times? — Reads like English. times is a message sent to the number 5. The block receives the iteration index as _. The number itself knows how to repeat — no for keyword, no loop variable declaration.
xs : [1, 2, 3]
xs 1xs = [1, 2, 3]
xs[0]xs = [1, 2, 3]
xs[0]Why 1-based? — Indexing is just sending a number to a list. xs 1 means "send 1 to xs" — same syntax as a function call. 1-based because the first element is element 1. No bracket syntax for indexing — it's the same operation as everything else.
xs collect [_ * 2]xs.map(x => x * 2)[x*2 for x in xs]Why _ instead of naming the variable? — For single-argument blocks, naming the parameter is ceremony. _ means "the thing I was given." Reads naturally: "collect each-thing times 2." For multi-arg blocks, you name them with |.
xs fold 0 [a, b | a + b]xs.reduce((a,b) => a+b, 0)reduce(lambda a,b: a+b, xs, 0)Why fold not reduce? — fold reads better with the initial value: "fold starting from 0." The accumulator and element are named parameters. Python needs an import; JS puts the initial value last; Sprout keeps it between the list and the block.
xs each [_ print]xs.forEach(x => console.log(x))for x in xs: print(x)Why message-style iteration? — each is a message sent to the list. The list knows how to iterate itself. No for keyword, no loop variable declaration. The block receives each element as _.
"hi " ++ name`hi ${name}`f"hi {name}"Why ++ not +? — + is arithmetic. ++ is explicitly "join two things together." No type coercion ambiguity — 1 + "2" is always a type error, never silent string concatenation. Explicit is better.
adder : [n | [_ + n]]const adder = n => x => x+nadder = lambda n: lambda x: x+nWhy does this just work? — Every block closes over its enclosing scope. The inner block [_ + n] captures n from the outer block. No special closure syntax. Python's lambda is limited to expressions; Sprout blocks have no such restriction.
compose : [f, g | [f (g _)]]const compose = (f,g) => x => f(g(x))compose = lambda f,g: lambda x: f(g(x))Why is this so short? — No return keyword (last expression is the return value). No argument parentheses (juxtaposition). _ instead of naming a throwaway parameter. The entire language optimizes for blocks-that-transform-values.
fib : [n | ...] memoize// manual cache or library@lru_cache(maxsize=None)
def fib(n): ...Why postfix memoize? — It's a message sent to the block: "take this block and make it memoized." Reads naturally. Python needs a decorator + import. JS needs manual implementation. Sprout treats it as a block transformation — same as any other message.
[_ * 2]x => x * 2lambda x: x * 2Why @? — Most blocks take one argument and use it immediately. Naming it is noise. @ is the "current thing" — like it in Kotlin or $_ in Perl, but shorter and always available. When you need names, use |.
'Variables and functions' x : 42 name : "Sprout" add : [a, b | a + b] add 3 4 print 'List operations' nums : [1, 2, 3, 4, 5] nums collect [_ * _] print nums fold 0 [a, b | a + b] print 'Closures' adder : [n | [_ + n]] add10 : adder 10 add10 5 print 'Conditional' abs : [_ < 0 [0 - _, _]] abs -7 print 'Iteration' 5 times [_ print] nums each [_ print]
# Variables and functions x = 42 name = "Sprout" add = lambda a, b: a + b print(add(3, 4)) # List operations nums = [1, 2, 3, 4, 5] print([n * n for n in nums]) from functools import reduce print(reduce(lambda a, b: a + b, nums, 0)) # Closures def adder(n): return lambda x: x + n add10 = adder(10) print(add10(5)) # Conditional abs_val = lambda x: -x if x < 0 else x print(abs_val(-7)) # Iteration for i in range(1, 6): print(i) for n in nums: print(n)
'Fibonacci with memoize'
fib : [n |
n <= 1 [n,
(fib (n - 1)) + (fib (n - 2))
]
] memoize
fib 30 print
'FizzBuzz'
fizzbuzz : [n |
n % 15 = 0 ["FizzBuzz",
n % 3 = 0 ["Fizz",
n % 5 = 0 ["Buzz", n]
]
]
]
20 times [fizzbuzz _ print]
'Higher-order'
compose : [f, g | [f (g _)]]
inc : [_ + 1]
dbl : [_ * 2]
incdbl : compose dbl inc
incdbl 10 print
# Fibonacci with memoize
from functools import lru_cache
@lru_cache(maxsize=None)
def fib(n):
if n <= 1: return n
return fib(n - 1) + fib(n - 2)
print(fib(30))
# FizzBuzz
def fizzbuzz(n):
if n % 15 == 0: return "FizzBuzz"
if n % 3 == 0: return "Fizz"
if n % 5 == 0: return "Buzz"
return n
for i in range(1, 21): print(fizzbuzz(i))
# Higher-order
compose = lambda f, g: lambda x: f(g(x))
inc = lambda x: x + 1
dbl = lambda x: x * 2
incdbl = compose(dbl, inc)
print(incdbl(10))
'GCD' gcd : [a, b | b = 0 [a, gcd b (a % b)] ] gcd 48 18 print 'Strings' greet : [name | "Hello, " ++ name ++ "!" ] greet "World" print 'Not — as a library function' not : [_ [false, true]] not true print not false print
# GCD
def gcd(a, b):
if b == 0: return a
return gcd(b, a % b)
print(gcd(48, 18))
# Strings
def greet(name):
return f"Hello, {name}!"
print(greet("World"))
# Not — built-in keyword
# (can't redefine 'not' in Python)
print(not True)
print(not False)