Sprout

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.

Language Spec

One operation

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)

One construct: [ ]

Object, block, list, callable. All the same thing.

[1, 2, 3] -- list [_ + 1] -- function [n | n * 2] -- named param [] -- none

Init vs Mutate

: creates a field (lazy). := updates an existing one.

x : 0 -- init x := x + 1 -- mutate x := x + 1 -- mutate again

_ implicit arg

Single-arg blocks use _. No naming ceremony.

double : [_ * 2] square : [_ * _] 5 times [_ print]

Selection (no if/else)

Booleans select: true picks first, false picks second.

x > 0 ["positive", "negative"] n % 2 = 0 ["even", "odd"]

Counter (init + mutate)

: runs once. := runs every call. State persists.

counter : x : 0 x := x + 1 counter -- 1 counter -- 2 counter -- 3

Dispatch tables

Named methods are fields on the cell.

account : balance : 0 deposit amt : balance := balance + amt alice : "alice" account alice "deposit" 50

Copy-on-pass

Arguments deep-copied. Receivers aliased. No aliasing bugs.

-- receiver aliased (can mutate) alice "deposit" 50 -- argument copied (safe) f xs -- f gets a copy

Lazy [ ] vs Eager ( )

[] inert until messaged. () runs immediately.

thunk : [heavy-computation] -- runs only when messaged result : (heavy-computation) -- runs now

Prototypes

Numbers, strings, booleans have prototype cells. Operators are methods.

-- 3 + 5: 3 receives + -- returns partial, receives 5 -- custom ops on your cells: vector : + other : ...
Cheat sheet: name : val init · name := val mutate · [...] lazy cell · (...) eager · _ implicit arg · params | body named params · obj msg send · -- comment · * name shared
How Tiny?
1,330
lines of compiler
(tokenizer + parser + codegen + runtime)
0
keywords
if/while/for/class/function — all library code
133
tests pass
COW, persistence, immutability, reconciler
1
operation
obj arg — apply is the only verb

Sprout's entire grammar is smaller than:

  • A favicon.ico (≈1KB)
  • A QR code (≈2KB)
  • This bulleted list
How Fast?
1.0–1.4×
native JS
N-body, sieve — within JIT reach
<1ms
compile time
source → JS in microseconds
30–100×
faster than Python
on compute benchmarks
Float64
native arrays
dense packed, V8 JIT-friendly

"Not bad for a language that fits on a napkin."

View full benchmarks →

The Big Ideas

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.

Everything is a cell
100%
No primitives, no special cases. Numbers, strings, blocks, lists — all cells."We committed to the bit."
All cells are continuations
60%
Every object can be suspended and resumed. The stack IS the object."Goto considered harmful? We made it the only thing."
Persistent by default
100%
Top-level fields are durable by default. Relational schema — no JSON. Scalars in typed columns, list elements one row each. Versioned with signature-based dedup. ephemeral opts out."Like a Tamagotchi you can't kill. SELECT * FROM your_program."
Immutable + shared flags
100%
Tag any value 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."
Pass-by-value (deep copy)
100%
Every call copies mutable args at the boundary. No aliasing bugs, no spooky action at a distance. Thread-safe by default. Proven-local and immutable values skip the copy."Your objects, your problems. Not mine."
Hybrid value model
100%
Default: bare 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."
Tail call optimization
100%
Recursive functions don't blow the stack. Trampolined in JS."Recursion without consequences."
Automatic currying
100%
Multi-arg functions auto-curry. add 3 returns a function."Every function is patient."
No keywords at all
100%
if/else/while/for/class/function/return — all built from selection + blocks."The language has nothing to say."
Selection as control flow
100%
Booleans select from lists. true picks first, false picks second."If statements hate this one weird trick."
Memoization built-in
100%
Append memoize to any block. Free caching."Your function is now a lookup table."
Polymorphic iteration
100%
One semantics: walk the elements. Proven-dense targets compile to bare inline loops (zero overhead). Maps/unknowns dispatch to ITER_* helpers. each/collect/fold work on anything."One loop to rule them all."
Tiny React + Redux
100%
Pure reducer, immutable state, replay=fold (time-travel for free). Recursive vdom reconciler producing patches as data. All pure Sprout, fully tested."We built React. In the language. That we built. In 1,330 lines."
An index IS a call
100%
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."
Pulse: React+Redux in Sprout
100%
Pure reducer (fold = dispatch). Recursive vdom reconciler producing patches as data. View = f(state). Time-travel = replay the action log. All pure Sprout, fully tested. No DOM, no framework, no dependencies."We built React. In the language. That we built. In 1,330 lines. We may have a problem."
Promote-on-demand (Hybrid)
100%
Default: bare 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."
Flags are fields
100%
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."
Code is data (homoiconicity)
90%
Functions persist as re-compilable source text. Data persists as typed columns. Both are fields in the same table. checkpoint saves everything. Your program's code and its state live in the same SQLite database."Is it a program or a database? Yes."
Versioned persistence
100%
Every save creates a new version. Unchanged fields skip the write (signature-based dedup). History is retained for time-travel. Scalars in native typed columns. List elements one row each. No JSON anywhere."Your program has an undo button. You didn't even ask for one."
Ephemeral opt-out
100%
Tag a field 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."
Prelude is Sprout
100%
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."
Image-based development
25%
Smalltalk-style: your whole program is a live image. Persistence is the foundation — the rest is UI."Like a video game save file, but for code."
Hot code reload
20%
Swap definitions without restarting. Persistence + source-as-value makes it possible. Infrastructure exists; UX doesn't."The program is the IDE is the program. (In theory.)"
Transparent concurrency
5%
No async/await. No function coloring. Blocks suspend transparently via continuations. Designed on paper, not built."We'll get to it. Eventually. Transparently."
LuaJIT backend
0%
Would settle the speed question on numeric code. Native Love2D target. Planned, not started."When we want to go REALLY fast, we'll call Mike Pall."

Deep Copy Semantics — The Good, The Bad, The Clever

The Good

  • No aliasing bugs. Ever. Change x, y doesn't change.
  • Thread-safe by default. No locks needed.
  • Undo is free — just keep the old copy.
  • Equality is structural, not referential.

The Bad

  • Copying a 10,000-element list for every operation? Yikes.
  • Nested objects = nested copies. Turtles all the way down.
  • "My program is 90% memcpy" — actual developer quote (it was us)

The Clever

  • Copy at the PASS boundary, not per-write. Hot mutation stays barrier-free.
  • 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.
  • N-body runs at 1.0× native JS. The cost is proved away.
01 Quickstart

Variables

Bind a name with : — that's it.

x : 42 name : "Sprout" x print -- x := x + 1 (mutate)

Blocks

Blocks are closures in square brackets. _ is the implicit argument.

double : [_ * 2] double 5 print add : [a, b | a + b] add 3 4 print

Messages

Everything is message-passing. Chain messages left to right.

inc : [_ + 1] double : [_ * 2] inc 4 print double (inc 4) print

Selection

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

Recursion

Blocks can call themselves. Tail calls are optimized.

fib : [n | n <= 1 [n, (fib (n - 1)) + (fib (n - 2)) ] ] fib 10 print

Lists

Brackets with 2+ elements make a list. 1-based indexing.

nums : [10, 20, 30] nums 1 print nums length print nums each [_ print]
02 Language Reference

Assignment

Bind or rebind with name : value.

x : 10 x : x + 1

Operators

Arithmetic, comparison, and string concat.

+ - * / % -- math = != > < >= <= -- compare ++ -- concat

Strings

Double-quoted, with escape sequences.

"hello" length print "foo" ++ " bar" print "abc" = "abc" print

Comments

Single-quoted text is a comment.

-- this is a comment x : 42 -- inline too

Loops

while, times, each — all via blocks.

-- count to 5 5 times [_ print] -- while loop i : 0 while [i < 10] [i := i + 1] i print

Collections

each, collect, fold — list operations.

xs : [1, 2, 3, 4, 5] xs collect [_ * _] print xs fold 0 [a, b | a + b] print

Higher-Order

Compose, curry, and pass blocks around.

compose : [f, g | [f (g _)]] inc : [_ + 1] dbl : [_ * 2] incdbl : compose dbl inc incdbl 10 print

Pure / Memoize

Mark a block pure or memoize for caching.

fib : [n | n <= 1 [n, (fib (n - 1)) + (fib (n - 2)) ] ] memoize fib 30 print
03 Library Patterns

Sprout has no keywords for control flow, boolean logic, or pattern matching. These patterns emerge naturally from selection, closures, and message-passing.

not

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

match

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

when / unless

Conditional value — returns the value when true, none otherwise.

when : [cond, val | cond [val, none]] when true "yes!" print when false "nope" print

identity & const

id returns its argument unchanged. const ignores the second argument.

id : [_] const : [a, b | a] id 42 print const "keep" "ignore" print

pipe

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

flip

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

church numerals

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

safe-div & chaining

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

fizzbuzz one-liner

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 ]

micro test

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

S K I combinators

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
04 Example Gallery
05 Sprout vs. Others
Basics
Variable
Sproutx : 42
JSconst x = 42
Pythonx = 42

Why : 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.

Function
Sproutadd : [a, b | a + b]
JSconst add = (a,b) => a+b
Pythonadd = lambda a,b: a+b

Why 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.

Call
Sproutadd 3 4
JSadd(3, 4)
Pythonadd(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.

Print
Sprout42 print
JSconsole.log(42)
Pythonprint(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.

Comment
Sprout'this is a comment'
JS// this is a comment
Python# this is a comment

Why 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.

Control Flow
Conditional
Sproutx > 0 [yes, no]
JSx > 0 ? yes : no
Pythonyes if x > 0 else no

Why 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 loop
Sproutwhile [i < 10] [i : i + 1]
JSwhile (i < 10) { i++ }
Pythonwhile i < 10: i += 1

Why 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.

Repeat N times
Sprout5 times [_ print]
JSfor (let i=1; i<=5; i++) ...
Pythonfor 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.

Data & Collections
List + Index
Sproutxs : [1, 2, 3] xs 1
JSxs = [1, 2, 3] xs[0]
Pythonxs = [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.

Map / Collect
Sproutxs collect [_ * 2]
JSxs.map(x => x * 2)
Python[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 |.

Reduce / Fold
Sproutxs fold 0 [a, b | a + b]
JSxs.reduce((a,b) => a+b, 0)
Pythonreduce(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.

Each / Iterate
Sproutxs each [_ print]
JSxs.forEach(x => console.log(x))
Pythonfor 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 _.

String concat
Sprout"hi " ++ name
JS`hi ${name}`
Pythonf"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.

Functions & Closures
Closure
Sproutadder : [n | [_ + n]]
JSconst adder = n => x => x+n
Pythonadder = lambda n: lambda x: x+n

Why 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
Sproutcompose : [f, g | [f (g _)]]
JSconst compose = (f,g) => x => f(g(x))
Pythoncompose = 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.

Memoize
Sproutfib : [n | ...] memoize
JS// manual cache or library
Python@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.

Implicit arg
Sprout[_ * 2]
JSx => x * 2
Pythonlambda x: x * 2

Why @? — 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 |.

05b Full Programs — Side by Side

Sprout

'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]

Python

# 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)

Sprout

'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

Python

# 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))

Sprout

'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

Python

# 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)