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_user

true and false are their own literals (not atoms). nil stands for absence.

true
false
nil

The 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'   # charlist

Interpolation 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 = 2

Prefixing with _ ignores a value or silences unused-variable warnings.

_unused = 1
_ = fun()
{:ok, _value} = result

Lists

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 update

Generic 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 end

Default parameters use a double backslash (\\), never =.

def greet(name, prefix \\ "Hello") do
  "#{prefix}, #{name}!"
end

Multi-head defines several clauses selected by matching, refined with guards (when).

def classify do
  (0)             -> :zero
  (n) when n > 0  -> :positive
  (n)             -> :negative
end

Lambdas

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

Lambdas 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/2

FFI

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})"
end

The $ 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 b

The 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()    # 12

Types

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 :: integer

Nullable (?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 ... end

In 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
end

Pattern 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} = result

Type guards in patterns: ::type is sugar for _ :: type.

case m do
  ::integer -> "number"
  ::string  -> "text"
  ::nil     -> "absent"
end

Structs match by type and fields, with optional guards.

case item do
  %Item{value: v} when v > 50 -> :high
  %Item{value: v}            -> :low
end

If / else

A simple conditional to choose between two paths. For multiple structured alternatives, prefer case.

if n > 0 do
  :positive
else
  :not_positive
end

Case

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
end

On ?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
end

Match

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
end
match {:ok, v} <- risky() rescue reason do
  {:handled, reason}
end

Comprehensions (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
# 10

The 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 ! :hello

receive 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
end

Modules 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}!"
end

A 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 end

as "application" declares an OTP application (generates the .app):

as "application"
name :my_app
vsn "0.1.0"
mod my_app
↑ Back to top