A guide for people writing PureScript and compiling it to Lua with pslua:
how PureScript values map onto Lua, what you need to know to write FFI, and the
handful of surprises worth knowing up front.
This page is for users of the compiler. Compiler-internal pitfalls live in
CLAUDE.mdand in inline source Notes.
pslua targets stock Lua 5.1. Code it emits — and any FFI you write — must
run there. The things most likely to trip you up, because they exist in later
Lua but not 5.1:
- Missing:
table.unpack/table.pack/table.move, thebit32library, theutf8library, the//floor-division operator. - Present (but easy to forget they moved later):
math.pow,math.atan2. - Tables are 1-indexed.
- Relational operators (
<,>, …) error on booleans instead of coercing. error(msg)at level ≥1 prependschunk:line:to your message. Useerror(msg, 0)to raise the raw string unchanged.
If your FFI uses a 5.2+ builtin it will work under a newer interpreter and fail under 5.1 — test on 5.1.
| PureScript | Lua |
|---|---|
Int, Number |
number (5.1 numbers are doubles) |
String, Char |
string (a byte string — see below) |
Boolean |
boolean |
Array a |
table, 1-indexed |
record { a, b } |
table keyed by field name (t.a, t.b) |
Unit |
{} (empty table) — never nil |
function a -> b -> c |
curried: call as f(a)(b) |
Effect a |
a nullary thunk function() … return a end; run it by calling m() |
| data constructor | table carrying a tag plus its fields |
Notes that bite in practice:
Unitis{}, notnil— because Lua tables can't storenil(anilfield just doesn't exist). The same rule applies to your own FFI: never putnilinto an array or record you build, or those elements silently vanish.- Strings/Chars are byte strings. A non-ASCII code point is several bytes.
Code-point-aware operations go through
Data.String.CodePoints;Data.String.CodeUnitsslices byte-wise. If you write FFI that consumes aChar, it may receive either a single byte (fromCodeUnits) or a full multibyte sequence (from a literal) — handle both, e.g. guard on#cbefore readingc:byte(2). Effectis a thunk. FFI that produces anEffect amust return a function that, when called, performs the action and returns thea. FFI that produces a plainareturns the value directly.
A foreign .lua file is an ordinary Lua 5.1 module: an optional header of
shared helpers followed by a single return of an exports table. The
compiler parses the whole file with its own Lua 5.1 parser, so a syntax error
anywhere in the file is a compile-time error.
- The header is any sequence of Lua statements (typically
localhelpers), in scope for the exported values. Lua's own grammar guarantees the exportsreturnis the last top-level statement. - The exports are a single returned table of fields. Each field must be keyed
by an identifier (
key = <lua expression>) or, for an export named after a Lua keyword, by a bracketed string (["if"] = …). Values need no special wrapping. - Comments are allowed anywhere, including between table fields, and are preserved in the generated output.
- Lua 5.1's "ambiguous syntax" rule applies, as it does under
luac: a line that starts with(right after an expression is rejected as an ambiguous function call — end the previous statement with;or keep the(on the same line as the callee. - Top-level
...is rejected: the file is embedded into a function scope in the generated output, so chunk varargs (e.g. thelocal name = ...module-name idiom) cannot work there....inside the file's own vararg functions is fine.
-- header: shared helpers
local function helper(x)
return x + 1
end
return {
foo = function(a) return helper(a) end,
-- comments between fields are fine
bar = function(a) return function(b) return a + b end end,
}A straight-line do block compiles to a chain of bind/discard whose
continuations nest lexically, and Lua's parser caps how deeply expressions may
nest (~200 levels). A long enough chain would fail to load (before any code
runs) with chunk has too many syntax levels.
The compiler now flattens these so they have no practical length limit:
EffectandSTblocks are lowered to a plain statement sequence (magic-do).- Any other monad —
Maybe,Either,State, a custom parser/decoder — has its continuation chain lambda-lifted: long chains of anyf action (\x -> …)shape (bind/>>=, non-bindCPS,bracket/withcombinators) are split into segments and each segment's continuation becomes a small named helper, so the generated nesting stays flat regardless of chain length (#104). - Applicative / flipped-bind chains whose depth lives in a value-argument
position —
ado/apply(<*>),bindFlipped(=<<), deep left-associated<>, or any other nested call spine — are sequentialised: the deepest application path is rebuilt into a flat sequence oflocalstatements, sealing a$tmpevery ~40 applications (segmented, so eachlocalstays shallow and the local count stays well under Lua's 200-per-function cap) (#108).
A few related shapes are not flattened yet and can still hit the limit at ~200+ levels in a single expression:
- A
bindchain that forwards more than ~15 distinct earlier-bound variables through a single cut: the lambda-lifter bails (a segment's helpers carry those forwarded variables plus the segment's own binders as upvalues, which would approach Lua 5.1's 60-upvalue cap, see below) and leaves it nested. - Deep nesting from branch constructs: a giant
casetree (nestedif/then/else), whose hoisting must stay branch- and laziness-aware, or a very wide array/record literal.
When one of these would overflow, the compiler now rejects it with a clear
error (Expression nests too deeply for Lua 5.1 …) instead of emitting a Lua
file that no interpreter can load. If you hit it: split the expression into
smaller named pieces (extract sub-computations into separate functions and
sequence them), or break a wide literal/decode into chunks.
Separate from the parser-nesting limit above, Lua 5.1 caps the contents of a single function at load time:
- ~200 local variables per function (
LUAI_MAXVARS). Exceeding it fails to load withfunction at line N has more than 200 local variables. - ~60 upvalues per function (
LUAI_MAXUPVALUES) — variables a closure captures from an enclosing scope. Exceeding it fails withfunction at line N has more than 60 upvalues. (Lua ≥5.2 raises this to 255; 5.1 is the floor pslua targets.)
Both have bitten the compiler before: long Effect chains used to build deeply
nested closures, each capturing many top-level references as upvalues
(#19). The
compiler now keeps its own output within these limits — Effect/ST do blocks
are flattened to a flat statement sequence (no capturing closure per step), and
that sequence is chunked so no generated function exceeds the local-variable
limit.
If you write your own FFI, the same caps apply to your Lua: a single function with hundreds of locals, or one that captures more than ~60 distinct outer variables, will not load under Lua 5.1 — split it into smaller functions.
Lua has proper tail calls, so a function whose recursive call is in tail
position (return go(next)) loops in constant stack. But non-tail recursion —
especially building up a monadic computation by recursing — can overflow the Lua
stack at runtime, just as on other backends.
For stack-safe monadic loops use Control.Monad.Rec.Class (tailRecM,
tailRecM2, forever) instead of open recursion. Its Effect instance runs as
a real loop, so it stays in constant stack regardless of iteration count.