Lx Syntax
The building blocks of the language, each concept in its own section.
Literals
Integers can be written in decimal, hexadecimal, octal or binary. The prefix sets the base.
42 # decimal
0xFF # hexadecimal (255)
0o77 # octal (63)
0b1010 # binary (10)Floats use a dot. Atoms are named constants prefixed with : and are widely used as tags.
3.14
:ok
:error
:active_usertrue and false are their own literals (not atoms). nil stands for absence.
true
false
nilThe atoms :nil and :undefined are reserved (error E029). Always use the nil literal for absence.
Strings and charlists
Double quotes produce binary strings (UTF-8). Single quotes produce charlists (lists of integers), used for Erlang interop.
"hello" # binary (UTF-8)
'hello' # charlistInterpolation evaluates expressions inside the string (syntax: # followed by braces). Escapes: \n \t \r \" \\.
name = "World"
"Hello #{name}!" # "Hello World!"To join ready-made strings use <> (concatenation). For single values, prefer interpolation.
Variables
Immutable bindings in snake_case. Assignment is actually pattern matching — each name is bound once per scope.
x = 42
result = x * 2
{a, b} = {1, 2} # a = 1, b = 2Prefixing with _ ignores a value or silences unused-variable warnings.
_unused = 1
_ = fun()
{:ok, _value} = resultLists
Linked lists (not arrays). The cons operator [head | tail] prepends in O(1).
[]
[1, 2, 3]
[0 | [1, 2, 3]] # [0,1,2,3]
[1, 2] ++ [3, 4] # [1,2,3,4]
[1, 2, 3] -- [2] # [1,3]To process lists, prefer the enum module functions (map, filter, reduce) or the for comprehension.
Tuples
Fixed-size collections, ideal for grouping values and tagging results like {:ok, value}.
{}
{1, 2}
{:ok, 42, "data"}
point = {3, 4}Tagged tuples are syntactic sugar — and work with the pipe operator.
ok{1} # {:ok, 1}
error{"msg", 500} # {:error, "msg", 500}
1 |> ok{} # {:ok, 1}Maps
Key-value with atom or expression keys. Access in plain maps is by brackets — the dot is reserved for structs.
m = %{x: 10, y: 20}
m[:x] # 10 (correct)
m.x # ERROR on a plain map (E025)The spread operator ... merges a whole map; the update syntax changes specific fields.
base = %{host: "localhost", port: 5432}
%{base | ...%{port: 5433, db: "app"}}
%{base | host: "db.local"}Structs
Named, typed maps with fixed fields. They enable dot access and matching by type. Literals use %Name{...}.
struct User {
name :: string
age :: integer
}
u = %User{name: "Ada", age: 36}
u.name # "Ada"
%{u | age: 37} # immutable updateGeneric structs use type parameters with $. External structs use the module prefix.
struct Socket[$a] {
assigns :: $a
}
s = %Socket[State]{}
%lxapp:Column{children: [...]}Functions
def (public), defp (private), with types and multiple heads. The return type comes after the parameters.
def add(a :: integer, b :: integer) :: integer do
a + b
end
defp helper(x) do x * 2 endDefault parameters use a double backslash (\\), never =.
def greet(name, prefix \\ "Hello") do
"#{prefix}, #{name}!"
endMulti-head defines several clauses selected by matching, refined with guards (when).
def classify do
(0) -> :zero
(n) when n > 0 -> :positive
(n) -> :negative
endLambdas
Anonymous functions. Calling requires the dot syntax: f.(arg).
inc = fn(x) -> x + 1 end
inc.(5) # 6
add = fn(a, b) do a + b end
add.(2, 3) # 5Lambdas can be passed as arguments and captured from named functions with &name/arity.
enum:map([1, 2, 3], fn(x) -> x * 2 end)
action = &helper/2FFI
Emit native Erlang code directly, with parameter interpolation. Mark the function with $ after the parameters.
def write(path :: string, content :: string)$ :: :ok do
"file:write_file(#{path}, #{content})"
endThe $ goes AFTER the parameters: def foo(x)$ do ... end. The body must be a single string.
Operators
Division / is float; div and rem are integer. Strings use <>; lists use ++ and --.
7 / 2 # 3.5 (float)
7 div 2 # 3 (integer)
7 rem 2 # 1 (remainder)
"a" <> "b" # "ab"
[1] ++ [2] # [1,2]Standard comparators and === (strict). Logical: and/or/not and andalso/orelse (short-circuit).
1 == 1.0 # true
1 === 1.0 # false
a and b
a orelse bThe in operator tests membership. The pipe |> chains calls.
3 in [1, 2, 3] # true
[1, 2, 3]
|> enum:map(fn(x) -> x * 2 end)
|> enum:sum() # 12Types
Static typing with inference. type creates aliases and unions; type parameters use $.
type status :: :ok | :error
type point :: {integer, integer}
type result($t) :: {:some, $t} | :none
type pair($a, $b) :: {$a, $b}opaque hides the type definition (only the module sees it).
type opaque user_id :: integerNullable (?T)
?T makes explicit that a value may be missing — it is sugar for T | nil.
x :: ?integer # integer | nil
def find(list, fun) :: ?integer do ... endIn a case, ?T values must cover absence (exhaustiveness E030). The ::nil pattern matches both nil and Erlang's undefined.
case result do
::integer -> result
::nil -> :not_found
endPattern matching
The heart of Lx: matching values against patterns. Works in =, case, function heads, with, match and receive.
{:ok, x} = {:ok, 42} # x = 42
[a, b | _] = [1, 2, 3, 4] # a=1, b=2
{:ok, _value} = resultType guards in patterns: ::type is sugar for _ :: type.
case m do
::integer -> "number"
::string -> "text"
::nil -> "absent"
endStructs match by type and fields, with optional guards.
case item do
%Item{value: v} when v > 50 -> :high
%Item{value: v} -> :low
endIf / else
A simple conditional to choose between two paths. For multiple structured alternatives, prefer case.
if n > 0 do
:positive
else
:not_positive
endCase
Match a value against several clauses, with guards. The most used control-flow structure. Each clause has a pattern left of ->.
case value do
{:ok, v} -> v
{:error, _} -> nil
n when n > 0 -> :positive
_ -> :default
endOn ?T values, remember to cover ::nil (exhaustiveness E030).
With
Chain happy-path matches in sequence, short-circuiting on the first failure. If a step fails, with returns that value immediately.
result = with {:ok, a} <- fetch(),
{:ok, b} <- parse(a) do
b * 2
endMatch
Like with for a single match. Returns the failed value on mismatch and supports guard and rescue.
match {:ok, v} when v > 0 <- expr do
v
endmatch {:ok, v} <- risky() rescue reason do
{:handled, reason}
endComprehensions (for)
Map, filter and reduce in a single expression. The simplest for maps each element; when filters.
for x in [1, 2, 3] do x * 2 end # [2,4,6]
for x in 1..5 when x > 2 do x * 2 end # [6,8]With an accumulator (acc = ...), for becomes a reduce, returning the final value.
for x in [1, 2, 3, 4], acc = 0 do
acc + x
end
# 10The for body must be a single expression. For complex logic, extract a function.
Concurrency
On the Erlang/OTP target: lightweight processes and message passing. spawn creates an isolated process with its own mailbox.
pid = spawn(fn() -> :ok end)
pid ! :helloreceive takes messages from the mailbox by matching, with clauses like case. after enables a timeout.
receive do
{:ok, data} -> data
{:error, _} -> :error
after
5000 -> :timeout
endModules and directives
require brings modules into scope: @lx/ for the stdlib, a name for local libraries, or a relative path in multi-app projects.
require "@lx/io"
require "@lx/string"
require "cowboy"
require "../app1/helper"defmodule creates a submodule (generates a separate .erl). as behavior declares OTP behaviors.
defmodule my_worker do
def init(args) do {:ok, args} end
end
as behavior "gen_server"Documentation is written with doc comments: ## for a single line and ##-- --## for several lines. They support Markdown and feed the docs generator and LSP hover.
A function doc is the ## (or ##-- --## block) right after do, as the first line of the body — it attaches to a public function (on defp it's just a comment and emits W012):
def add(a, b) do
## Adds two integers.
a + b
end
def greet(name, prefix \\ "Hi") do
##--
Greets someone with an optional prefix.
- `name` :: the person's name
- `prefix` :: greeting (default "Hi")
--##
"#{prefix}, #{name}!"
endA module doc is the ## at the top of the module (before any def):
## Math API for the app.
def add(a, b) do a + b endas "application" declares an OTP application (generates the .app):
as "application"
name :my_app
vsn "0.1.0"
mod my_app
↑ Back to top