← Code Compare

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.

Show: ErlangElixirGleamLFELuerl
Erlang
-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.

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

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

LFE
(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.

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