Types & Records
Every BEAM language needs a way to name a structured value and update it without mutation. The task is identical everywhere: model a Point with integer x and y, build (1, 2), then translate it by (3, 4) into a new point (4, 6). Watch the spectrum - Erlang's compile-time records and Elixir's map-backed structs both lean on #point{...}/%Point{} update syntax, Gleam defines a real custom type whose fields are checked by the compiler, LFE generates record macros from defrecord, and Luerl falls back to a plain Lua table with a constructor function. In each, the original point is left untouched.
-module(geometry).
-export([demo/0]).
%% A record is a named tuple with compile-time field names.
-record(point, {x = 0 :: integer(), y = 0 :: integer()}).
translate(#point{x = X, y = Y} = P, Dx, Dy) ->
%% #point{...=...} update syntax copies P with new fields.
P#point{x = X + Dx, y = Y + Dy}.
demo() ->
P0 = #point{x = 1, y = 2},
P1 = translate(P0, 3, 4),
io:format("~p~n", [P1]).
%% demo() prints: {point,4,6}An Erlang -record compiles down to a tagged tuple, and P#point{x = ...} produces a fresh copy with selected fields replaced. The optional :: integer() annotations are checked by Dialyzer, not the compiler.
defmodule Point do
# A struct is a map with a fixed set of keys and a __struct__ tag.
defstruct x: 0, y: 0
def new(x, y), do: %Point{x: x, y: y}
def translate(%Point{x: x, y: y} = p, dx, dy) do
# %Point{p | ...} returns an updated copy; p is unchanged.
%Point{p | x: x + dx, y: y + dy}
end
end
p0 = Point.new(1, 2)
p1 = Point.translate(p0, 3, 4)
IO.inspect(p1)
# => %Point{x: 4, y: 6}Elixir's defstruct builds a map keyed by atoms with a __struct__ tag, and %Point{p | ...} does an immutable update that fails to compile if you name a key the struct doesn't have.
import gleam/io
import gleam/int
// A custom type with one constructor and two labelled Int fields.
pub type Point {
Point(x: Int, y: Int)
}
pub fn translate(point: Point, dx: Int, dy: Int) -> Point {
// Record-update syntax: copy `point`, overriding x and y.
Point(..point, x: point.x + dx, y: point.y + dy)
}
pub fn main() {
let p0 = Point(x: 1, y: 2)
let p1 = translate(p0, 3, 4)
io.println(
"Point(" <> int.to_string(p1.x) <> ", " <> int.to_string(p1.y) <> ")",
)
// prints: Point(4, 6)
}Gleam's Point is a statically typed custom type; the constructor and field accessors (p1.x) are type-checked, and Point(..point, ...) is record-update spread that the compiler verifies field by field.
(defmodule geometry
(export (demo 0)))
;; defrecord generates make-point, point-x, set-point-x, etc.
(defrecord point
(x 0)
(y 0))
(defun translate (p dx dy)
;; set-point-* returns a fresh copy with that field replaced.
(let ((nx (+ (point-x p) dx))
(ny (+ (point-y p) dy)))
(set-point-y (set-point-x p nx) ny)))
(defun demo ()
(let* ((p0 (make-point x 1 y 2))
(p1 (translate p0 3 4)))
(io:format "~p~n" (list p1))))
;; (geometry:demo) prints: {point,4,6}LFE's defrecord macro generates a family of functions - make-point, the accessors point-x/point-y, and the copy-on-write setters set-point-x/set-point-y - over the same tagged-tuple representation Erlang uses.
-- Lua has no record type, so a table with named fields stands in.
local function new_point(x, y)
return { x = x, y = y }
end
local function translate(p, dx, dy)
-- Build a brand-new table; p is never mutated.
return { x = p.x + dx, y = p.y + dy }
end
local p0 = new_point(1, 2)
local p1 = translate(p0, 3, 4)
print(string.format("Point(%d, %d)", p1.x, p1.y))
--> Point(4, 6)Standard Lua models a record as a table with x and y keys; immutability is a convention here - translate deliberately returns a freshly built table instead of assigning into p.