Expressive · Functional · Beginner-first
Lume is a small, functional language designed around four values:
- Expressiveness - say a lot with a little
- Legibility - code is for humans first
- Predictability - no surprises, no magic
- Smallness - a tiny core; everything else is library
Lume draws from Lua (small core, files as values), Elm and PureScript (row polymorphism, result types, immutability), and Haskell (type inference, pattern matching, pipelines). The sharp edges from each are filed off.
-- single-line comment (no multi-line comments)
Keywords: let pub type use trait in if then else match true false and not
Identifiers: [a-z][a-zA-Z0-9_]* for values and fields
Type names / variant names: [A-Z][a-zA-Z0-9]*
Type variables: single lowercase letters - a, b, r
Literals:
| Kind | Example |
|---|---|
| Number | 42 3.14 -7 |
| Text | "hello" "it's fine" |
| Bool | true false |
| List | [1, 2, 3] [] |
| Record | { name: "Alice", age: 30 } |
-- immutable binding
let x = 42
let name = "Alice"
-- function binding (a function is just a value)
let double = n -> n * 2
-- multi-argument (curried by default)
let add = a -> b -> a + b
-- function definition sugar: `let f x y = body` is shorthand for `let f = x -> y -> body`
let add a b = a + b
-- with optional type annotation
let greet : Text -> Text = name -> "Hello, " ++ name
-- annotated sugar form (parameter types inline, return type after ->)
let scale (x: Num) (factor: Num) -> Num = x * factor
All bindings are immutable. There is no assignment or mutation.
Lume is expression-oriented - everything evaluates to a value. There are no statements.
let can be used inside an expression with in to introduce a local binding:
let result = let x = 10 in x * x -- 100
let hypotenuse a b =
let a2 = a * a in
let b2 = b * b in
(a2 + b2)
Two or more bindings that call each other must be declared together using and:
let even = n -> if n == 0 then true else odd (n - 1)
and
let odd = n -> if n == 0 then false else even (n - 1)
double 5 -- 10
add 3 4 -- 7
Function application is left-associative and requires no parentheses for simple arguments. Parentheses group sub-expressions:
add (double 3) 4 -- 10
saveUser { name: "Alice" }
The |> operator passes the left-hand value as the last argument to the right-hand function:
5 |> double -- 10
[1,2,3] |> map double -- [2,4,6]
-- chains read top-to-bottom
scores
|> filter (s -> s >= 60)
|> map (s -> s * 1.05)
|> average
n -> n * 2
a -> b -> a + b
{ name, .. } -> name
if x > 0 then "positive" else "non-positive"
-- multi-line
if b == 0
then Err "cannot divide by zero"
else Ok (a / b)
| Type | Description | Examples |
|---|---|---|
Num |
64-bit float | 1 3.14 -7 |
Text |
UTF-8 string | "hello" |
Bool |
Boolean | true false |
List a |
Homogeneous list | [1, 2, 3] |
Maybe a |
Optional value | Some x / None |
Result a b |
Success or failure | Ok x / Err e |
let alice = { name: "Alice", age: 30, role: "admin" }
-- field shorthand: if variable name matches field name
let name = "Bob"
let bob = { name, age: 25 } -- same as { name: name, age: 25 }
alice.name -- "Alice"
alice.age -- 30
The ..expr spread syntax copies all fields from a record into a new record literal. Fields listed after a spread override the spread's values; fields before a spread are included first:
-- update: creates a new record; alice is unchanged
let older = { ..alice, age: 31 }
-- extend with a new field
let scored = { ..alice, score: 100 }
-- multiple spreads: later entries win on conflict
let merged = { ..defaults, ..overrides }
-- interleave static fields and spreads freely
let r = { a: 0, ..base, b: 9 }
Functions can accept any record that has at least the required fields. The .. in a type annotation means "and any other fields":
-- open row: accepts { name: Text } and anything else
let greet : { name: Text, .. } -> Text
let greet = { name, .. } -> "Hello, " ++ name
greet { name: "Alice", age: 30 } -- works
greet { name: "Bob", role: "admin" } -- works
-- closed row: accepts EXACTLY { name: Text, age: Num }
let strict : { name: Text, age: Num } -> Text
A function that adds a field:
let withScore : { name: Text, .. } -> { name: Text, score: Num, .. }
let withScore = rec -> { ..rec, score: 100 }
-- unit variants (no payload)
type Direction =
| North
| South
| East
| West
-- variants with a single wrapped value
type Shape =
| Circle Num -- wraps a Num (the radius)
| Rect Num Num -- not supported: only one payload allowed per variant
-- variants with labelled record payloads
type Shape =
| Circle { radius: Num }
| Rect { width: Num, height: Num }
-- mixed
type Answer =
| Yes
| No
| Maybe Text -- wraps a Text reason
-- recursive
type Expr =
| Num Num
| Add { left: Expr, right: Expr }
| Mul { left: Expr, right: Expr }
A variant payload is either:
- absent (unit variant):
| North - a single wrapped value (wrapper variant):
| Circle Num,| Ok a - a labelled record (record variant):
| Circle { radius: Num }
Type parameters are lowercase letters following the type name:
type Tree a =
| Leaf
| Node { value: a, left: Tree a, right: Tree a }
type Result a b =
| Ok a
| Err b
type Maybe a =
| Some a
| None
let c = Circle { radius: 5 } -- record variant
let r = Rect { width: 10, height: 4 }
let d = North -- unit variant: no braces needed
let n = None
let v = Ok 42 -- wrapper variant
let e = Err "oops"
let s = Some "hello"
-- field shorthand works for record variants
let radius = 7
let c2 = Circle { radius } -- same as Circle { radius: radius }
All sum types automatically support:
- Structural equality -
==and!= show- human-readable string representation for debugging
North == North -- true
Circle { radius: 5 } == Circle { radius: 5 } -- true
show (Rect { width: 3, height: 4 }) -- "Rect { width: 3, height: 4 }"
Pattern matching uses | arms, consistent with type definitions:
let describe : Shape -> Text
let describe =
| Circle { radius } -> "circle, r=" ++ show radius
| Rect { width, height } -> "rect " ++ show width ++ "x" ++ show height
Pattern matching supports guards and destructuring. Exhaustiveness checking is not currently guaranteed by this implementation.
The match ... in form matches an expression inline:
let label = match direction in
| North -> "up"
| South -> "down"
| East -> "right"
| West -> "left"
This is useful when the value being matched is not a function parameter.
.. in a pattern means "and any other fields I don't care about":
-- bind only what you need
let classify =
| { role: "admin", .. } -> "admin"
| { age, .. } if age < 18 -> "minor"
| { name, score, .. } -> name ++ ": " ++ show score
| _ -> "unknown"
Without .., the pattern matches exactly those fields and no others.
let classify =
| Circle { radius } if radius > 100 -> "huge"
| Circle { radius } if radius > 10 -> "medium"
| Circle _ -> "small"
| _ -> "not a circle"
Variant _ matches any payload without binding it.
Wrapper variants (single-value payload) bind the inner value directly:
let safeHead : List a -> Maybe a
let safeHead =
| [] -> None
| [x, ..] -> Some x
let getOrElse : a -> Maybe a -> a = default ->
| None -> default
| Some x -> x
let handleResult =
| Ok value -> "got " ++ show value
| Err reason -> "failed: " ++ reason
let first =
| [] -> None
| [x, ..] -> Some x
let second =
| [_, x, ..] -> Some x
| _ -> None
-- bind the tail
let headTail =
| [x, ..rest] -> Some { head: x, tail: rest }
| [] -> None
The same patterns work in let:
let { name, age, .. } = alice
let { name: userName, .. } = alice
let { address: { city, .. }, .. } = alice
let [first, ..rest] = myList
| Operator | Meaning |
|---|---|
|> |
Pipe - pass value into function |
?> |
Result pipe - pipe only if Ok |
-> |
Lambda / function arrow |
++ |
Concatenate (text, lists) |
| |
Match arm / type variant separator |
: |
Type annotation |
== != |
Structural equality |
< > <= >= |
Comparison (Num only) |
+ - * / |
Arithmetic (Num only) |
&& || |
Boolean and / or |
not |
Boolean negation (prefix) |
+ is numbers only. Text and list concatenation always uses ++. This avoids the classic beginner footgun of "5" + 3.
?> chains operations that return Result, short-circuiting on the first Err. It is defined in the standard library with fixity infixl 2:
let safeDivide = a -> b ->
if b == 0
then Err "division by zero"
else Ok (a / b)
safeDivide 10 2
?> (n -> Ok (n + 1)) -- Ok 6
safeDivide 10 0
?> (n -> Ok (n + 1)) -- Err "division by zero"
Any sequence of operator characters (e.g. <>, |>>, ~=) can be used as an infix operator by wrapping it in parentheses in a let binding. An optional fixity declaration controls associativity and precedence (0–9, default 9):
-- right-associative at precedence 6
let (++) infixr 6 = concat_text
-- left-associative at default precedence (9)
let (<>) infixl = a -> b -> a ++ b ++ a
-- non-associative at precedence 2 (chaining disallowed)
let (=?) infix 2 = a -> b -> a == b
Fixity declarations on trait methods are also supported:
trait Concat a {
let (++) infixr 6 : a -> a -> a
}
Precedence levels and their relationship to built-in operators:
| Prec | infixl bp |
vs built-ins |
|---|---|---|
| 0 | (0, 1) | below |> (10) |
| 5 | (40, 41) | same level as == (40) |
| 7 | (56, 57) | between ++ (50) and + (60) |
| 9 | (72, 73) | above * (70) |
The ..expr spread works in both list and record literals.
let xs = [1, 2, 3]
let ys = [4, 5]
let r1 = [..xs, 6] -- [1, 2, 3, 6]
let r2 = [0, ..xs] -- [0, 1, 2, 3]
let r3 = [..xs, ..ys] -- [1, 2, 3, 4, 5]
let r4 = [0, ..xs, 99, ..ys]
See §6.3 for record spread examples.
- Inferred - the compiler infers all types. Annotations are optional and serve as documentation.
- Sound - if the program compiles, it is type-correct. No runtime type errors.
- Row polymorphic - functions can be polymorphic over the "rest" of a record's fields (see §6.4).
- Trait-constrained - type annotations can require trait implementations (see §14).
- No implicit coercions -
Numnever becomesTextsilently. - No
nullorundefined- absence is represented byMaybe.
Type annotations use ::
let area : Shape -> Num
let greet : { name: Text, .. } -> Text
let withScore : { name: Text, .. } -> { name: Text, score: Num, .. }
let depth : Tree a -> Num
-- math.lume
let pi = 3.14159
let area = r -> pi * r * r
let circumference = r -> 2 * pi * r
pub {
area,
circumference,
pi,
}
Everything before pub is private. If a file omits pub, it implicitly exports the empty record {}.
Type declarations are module-local in the current implementation:
-- shapes.lume
type Shape =
| Circle { radius: Num }
| Rect { width: Num, height: Num }
let area =
| Circle { radius } -> 3.14 * radius * radius
| Rect { width, height } -> width * height
pub { area }
-- bind the whole module as a record
use math = "./math"
math.area 5 -- 78.53
-- destructure on import
use { area, pi } = "./math"
area 5 -- 78.53
-- rename on import
use { area: circleArea } = "./math"
use { area: rectArea } = "./geometry"
-- packages
use math = "lume:math"
use text = "lume:text"
-- relative paths
use utils = "./utils"
use cfg = "../config"
use is a static declaration - always at the top of the file, never inside a function or branch.
Operators defined in a module can be imported by wrapping the operator in parentheses:
use { (?>), (++) } = "lume:core"
Circular dependencies are a hard compile error. The compiler reports the full cycle:
Error: circular import detected
main.lume → a.lume
a.lume → b.lume
b.lume → main.lume
Resolve by extracting the shared definitions into a third module that neither imports.
A module can re-export selected bindings by importing them and publishing a new record:
use { area } = "./shapes"
use { pi } = "./math"
pub { area, pi }
The following are available globally - no import needed:
| Function | Type | Description |
|---|---|---|
show |
a -> Text |
Convert any value to text |
not |
Bool -> Bool |
Boolean negation |
max |
Num -> Num -> Num |
Larger of two numbers |
min |
Num -> Num -> Num |
Smaller of two numbers |
abs |
Num -> Num |
Absolute value |
round |
Num -> Num |
Round to nearest integer |
floor |
Num -> Num |
Round down |
ceil |
Num -> Num |
Round up |
| Function | Type | Description |
|---|---|---|
map |
(a -> b) -> List a -> List b |
Transform each element |
filter |
(a -> Bool) -> List a -> List a |
Keep matching elements |
fold |
b -> (b -> a -> b) -> List a -> b |
Reduce to a single value |
length |
List a -> Num |
Number of elements |
reverse |
List a -> List a |
Reverse a list |
take |
Num -> List a -> List a |
First n elements |
drop |
Num -> List a -> List a |
Skip first n elements |
zip |
List a -> List b -> List { fst: a, snd: b } |
Pair up two lists |
any |
(a -> Bool) -> List a -> Bool |
True if any match |
all |
(a -> Bool) -> List a -> Bool |
True if all match |
average |
List Num -> Num |
Arithmetic mean |
sum |
List Num -> Num |
Sum of elements |
sort |
List Num -> List Num |
Sort ascending |
sortBy |
(a -> Num) -> List a -> List a |
Sort by derived key |
| Function | Type | Description |
|---|---|---|
trim |
Text -> Text |
Remove surrounding whitespace |
split |
Text -> Text -> List Text |
Split on delimiter |
join |
Text -> List Text -> Text |
Join with separator |
contains |
Text -> Text -> Bool |
Substring check |
startsWith |
Text -> Text -> Bool |
Prefix check |
endsWith |
Text -> Text -> Bool |
Suffix check |
toUpper |
Text -> Text |
Uppercase |
toLower |
Text -> Text |
Lowercase |
length |
Text -> Num |
Character count |
| Function | Type | Description |
|---|---|---|
unwrap |
Result a e -> a |
Extract Ok or crash |
withDefault |
a -> Maybe a -> a |
Extract Some or default |
mapErr |
(e -> f) -> Result a e -> Result a f |
Transform Err value |
mapOk |
(a -> b) -> Result a e -> Result b e |
Transform Ok value |
mapMaybe |
(a -> b) -> Maybe a -> Maybe b |
Transform Some value |
orElse |
Maybe a -> Maybe a -> Maybe a |
First Some wins |
andThen |
(a -> Result b e) -> Result a e -> Result b e |
Result chaining helper |
Traits provide ad-hoc polymorphism — a way to define a shared interface that different types can implement independently. They are Lume's mechanism for overloading: the same function name (e.g. show) can behave differently depending on the type it is called with.
A trait declares one or more method signatures parameterised over a type variable:
trait Show a {
let show : a -> Text
}
trait Eq a {
let eq : a -> a -> Bool
}
The use ... in form provides an implementation for a concrete type:
use Show in Num {
let show = n -> showNum n
}
use Show in Bool {
let show = | true -> "true"
| false -> "false"
}
Implementations can target applied types (type constructors applied to arguments):
type Box a = | MyBox a
use Show in Box Num {
let show = MyBox inner -> "MyBox(" ++ Show.show inner ++ ")"
}
An impl can require that its type parameter already implements another trait. Constraints appear before =>:
use Show in Show a => List a {
let show = xs -> "[" ++ join ", " (map (x -> Show.show x) xs) ++ "]"
}
This says: "List a implements Show, provided a already implements Show." Multiple constraints are comma-separated:
use Printable in (Show a, Eq a) => Pair a {
let display = p -> Show.show p
}
Use Trait.method syntax to call a trait method. The compiler resolves which implementation to use based on the argument type:
Show.show 42 -- uses Show in Num
Show.show [1, 2, 3] -- uses Show in List a (which requires Show in Num)
Functions can require trait implementations on their type parameters using constraint annotations:
let showBoth : (Show a) => a -> a -> Text
let showBoth = x -> y -> Show.show x ++ " and " ++ Show.show y
The constraint (Show a) => means "this function works for any type a that has a Show implementation." Unparenthesized single constraints are also allowed:
let display : Show a => a -> Text
let display = x -> Show.show x
The compiler enforces several rules at type-check time:
- Missing impl: calling
Show.show xwherexhas a type with noShowimpl is a compile error. - Incomplete impl: an impl must provide all methods declared in the trait.
- Extra methods: an impl must not define methods not declared in the trait.
- Duplicate impl: two implementations for the same (trait, type) pair from different modules is a compile error. Diamond imports (same impl reaching a module via two paths) are allowed.
Errors are values. There are no exceptions.
-- functions that can fail return Result
let safeDivide : Num -> Num -> Result Num Text
let safeDivide = a -> b ->
if b == 0
then Err "division by zero"
else Ok (a / b)
-- handle with pattern matching
let result = safeDivide 10 2
| Ok value -> "got " ++ show value
| Err reason -> "failed: " ++ reason
-- or chain with ?>
safeDivide 10 2
?> (n -> Ok (n / 2))
?> (n -> Ok (n + 1))
-- Ok 3.5
Result values are ordinary values. ?> is defined in the prelude and chains computations that may fail.
A small program that reads a list of quiz scores, filters and grades them, and summarises the results:
-- grader.lume
type Grade =
| A | B | C | Fail
let toGrade : Num -> Grade
let toGrade =
| s if s >= 90 -> A
| s if s >= 75 -> B
| s if s >= 60 -> C
| _ -> Fail
let gradeLabel : Grade -> Text
let gradeLabel =
| A -> "A"
| B -> "B"
| C -> "C"
| Fail -> "Fail"
let process : List { name: Text, score: Num, .. } -> List { name: Text, grade: Text }
let process = students ->
students
|> filter ({ score, .. } -> score >= 0)
|> map ({ name, score, .. } ->
{ name, grade: gradeLabel (toGrade score) })
|> sortBy ({ grade, .. } -> grade)
pub { process, toGrade, gradeLabel }
-- main.lume
use { process } = "./grader"
let students =
[ { name: "Alice", score: 93, year: 2 }
, { name: "Bob", score: 71, year: 3 }
, { name: "Carol", score: 85, year: 2 }
, { name: "Dan", score: 55, year: 1 }
]
students
|> process
|> map ({ name, grade } -> name ++ ": " ++ grade)
|> join "\n"
|> show
-- Alice: A
-- Carol: B
-- Bob: C
-- Dan: Fail
| Feature | Reason omitted |
|---|---|
Mutation / var |
Immutability eliminates a class of bugs; use update syntax |
null / undefined |
Use Maybe - absence is explicit and handled |
| Exceptions | Use Result - errors are values |
| Classes / inheritance | Row polymorphism + traits cover the use cases more simply |
| Macros / metaprogramming | Keeps the language predictable and tooling simple |
| Concurrency primitives | Single-threaded; use packages for async I/O |
| Operator overloading | ++ for concat, + for numbers - unambiguous |
| Implicit coercions | All conversions are explicit |
program = use* (typedef | traitdef | impldef | binding_or_group)* ("pub" expr)?
use = "use" (ident "=" | record_pattern "=") string
typedef = "type" TypeName typevars "=" ("|" variant)+
variant = VariantName type?
| VariantName record_type
traitdef = "trait" TypeName ident "{" trait_method* "}"
trait_method = "let" ("(" op ")" fixity?)? ident ":" type
impldef = "use" TypeName "in" constraints? impl_type "{" impl_method* "}"
impl_type = TypeName type_primary*
impl_method = "let" ident (":" type)? "=" expr
constraints = constraint ("," constraint)* "=>"
| "(" constraint ("," constraint)* ")" "=>"
constraint = TypeName ident
binding_or_group = binding ("and" binding)*
binding = "let" binding_lhs (":" constraints? type)? "=" expr
binding_lhs = pattern
| ident param+ -- sugar: `let f x y = body`
fixity = ("infixl" | "infixr" | "infix") [0-9]?
expr = lambda | let_in | pipe_expr
lambda = pattern "->" expr
let_in = "let" pattern (":" type)? "=" expr "in" expr
pipe_expr = apply ("|>" apply)*
apply = result_pipe_expr
result_pipe = apply ("?>" apply)*
apply = atom atom*
| apply record_expr
atom = literal | ident | VariantName | trait_call
| list_expr | "(" expr ")" | if_expr
| match_expr | match_in_expr
trait_call = TypeName "." ident -- e.g. Show.show
if_expr = "if" expr "then" expr "else" expr
match_expr = ("|" pattern guard? "->" expr)+
match_in_expr = "match" expr "in" ("|" pattern guard? "->" expr)+
guard = "if" expr
pattern = "_"
| literal
| ident
| VariantName pattern?
| record_pattern
| list_pattern
record_pattern = "{" (field_pattern ",")* (".." ident? )? "}"
field_pattern = ident (":" pattern)?
list_pattern = "[" (pattern ",")* (".." ident?)? "]"
list_expr = "[" list_entry* "]"
list_entry = expr | ".." expr
record_expr = "{" record_entry* "}"
record_entry = ".." expr | ident (":" expr)? -- spread or field
type = TypeName type* -- applied type
| ident -- type variable
| record_type
| type "->" type -- function type
record_type = "{" (field_type ",")* ".."? "}"
field_type = ident ":" type
typevars = ident*
Lume - version 0.1 draft