Running It & Output
Hello, World
Both languages are read and executed by an interpreter embedded in a host program, so neither has a build step or an entry point to declare. The only difference in this row is the name of the output function.
print("Hello, World!")console.log("Hello, World!");Like Lua's
print, console.log appends a newline and accepts any number of arguments — though it separates them with a space rather than a tab.Printing several values
Both functions take a variable number of arguments and stringify each one, so this is a near-exact match — right down to printing booleans and the absent value without complaint.
local name = "Ada"
local count = 3
print(name, count, true, nil)const name = "Ada";
const count = 3;
console.log(name, count, true, null);The separator differs: Lua writes a tab between values and JavaScript writes a space. Note also that
console.log shows a string without quotes at the top level but with quotes when it is nested inside an object or array, which Lua never does.Comments
-- A single-line comment.
--[[ A long comment
spanning several lines. ]]
print("commented")// A single-line comment.
/* A block comment
spanning several lines. */
console.log("commented");Neither language's block comment nests, so wrapping a region that already contains one ends it early. Lua at least offers an escape — extra equals signs, as in
--[==[ … ]==] — which JavaScript has no equivalent of.Semicolons are optional in both
Both languages let you leave the semicolon off, and both insert it for you. The difference is how much trouble that mechanism can cause.
local first = "a" -- no semicolon
local second = "b"; -- legal, but nobody writes it
print(first, second)const first = "a" // no semicolon: inserted automatically
const second = "b"; // and most style guides write it anyway
console.log(first, second);Lua ends a statement only where the parse is unambiguous. JavaScript's automatic semicolon insertion has famous corner cases — most notably a
return followed by a newline, which silently returns undefined. That asymmetry is why JavaScript style guides argue about semicolons and Lua's do not.Variables & Scope
local becomes const or let
Lua has one declaration keyword and no notion of a binding that cannot be reassigned. JavaScript splits it in two, and the convention is to reach for
const first and let only when you actually reassign.local greeting = "hi" -- one keyword for everything
local counter = 0
counter = counter + 1
print(greeting, counter)const greeting = "hi"; // cannot be reassigned
let counter = 0; // can be
counter = counter + 1;
console.log(greeting, counter);const freezes the binding, not the value: a const array can still be pushed to. That is the same distinction Lua has between a local holding a table and the table's own contents — the difference is only that JavaScript can enforce the binding half.Assigning an undeclared name
This is Lua's most notorious footgun and JavaScript's too — a forgotten declaration keyword creates a global rather than an error. The languages diverge on whether you can turn it off.
local function set()
accidental = "global" -- no 'local': this is a GLOBAL
end
set()
print(accidental) -- visible everywhere"use strict";
function set() {
// accidental = "global"; <- ReferenceError under strict mode
globalThis.accidental = "explicitly global";
}
set();
console.log(globalThis.accidental);JavaScript grew a fix:
"use strict" makes an undeclared assignment a ReferenceError, and it is implied inside every module. Lua has no equivalent in the language, so projects reach for a strict.lua that installs a metatable on the globals table to catch it.Block scope
Both scope a declaration to the enclosing block and both let an inner block see outward. The bare
do … end block has a direct counterpart in a bare pair of braces.local outer = "visible"
do
local inner = "block only"
print(outer, inner)
end
print(outer, inner) -- inner is nil out hereconst outer = "visible";
{
const inner = "block only";
console.log(outer, inner);
}
console.log(outer, typeof inner); // inner is not even definedThe failure differs: reading
inner outside its block gives nil in Lua and throws a ReferenceError in JavaScript, which is why the example has to use typeof to ask about it safely. var is the exception that behaves more like Lua here, and is the reason let exists.Multiple assignment becomes destructuring
Lua evaluates the entire right-hand side before assigning, which is what makes its swap work. JavaScript reaches the same place through array destructuring, which is a different mechanism with the same effect.
local first, second = "a", "b"
print(first, second)
first, second = second, first
print(first, second)let first = "a", second = "b";
console.log(first, second);
[first, second] = [second, first];
console.log(first, second);Destructuring is the more general tool — it also pulls fields out of objects by name (
const { x, y } = point), which Lua has no syntax for at all. The leading bracket does need care: without a semicolon ending the previous line, JavaScript reads it as an index.nil, null, undefined, and Truthiness
🚨 Two falsy values become six
This is the most important row on the page, and the one that breaks ported code silently. Lua has exactly two falsy values,
nil and false. JavaScript has six: false, 0, "", null, undefined, and NaN.-- In Lua ONLY nil and false are falsy.
for _, value in ipairs({ 0, "", "0" }) do
if value then print(tostring(value) .. " is truthy") end
end// In JavaScript, 0 and "" are FALSY. "0" is truthy.
for (const value of [0, "", "0"]) {
if (value) console.log(String(value) + " is truthy");
else console.log(JSON.stringify(value) + " is FALSY");
}So
if count then in Lua is true for a count of zero, and if (count) in JavaScript is not. Any guard written around a number or a string changes meaning when carried across, and neither language will warn you. Test explicitly — if (count !== undefined) — rather than relying on truthiness.nil becomes null AND undefined
Lua answers "absent", "never assigned" and "deliberately empty" with the same
nil. JavaScript splits that into undefined — what you get for free — and null, which somebody had to write.local settings = { width = 80 }
print(settings.height) -- nil: the key is absent
local declared
print(declared) -- nil: never assigned
print(settings.height == declared) -- true: one value for bothconst settings = { width: 80 };
console.log(settings.height); // undefined: the key is absent
let declared;
console.log(declared); // undefined: never assigned
const cleared = null; // explicitly set to "no value"
console.log(settings.height === cleared); // false: NOT the same valueThe convention worth adopting is that
undefined means the language produced it and null means a programmer chose it. They are not equal under ===, though they are under ==, which is one of the few places the loose operator is genuinely useful: value == null tests for both at once.Deleting a key
In Lua, assigning
nil to a table key deletes it — there is no other way, and no way to store "present but empty". JavaScript treats those as different states.local settings = { width = 80, height = 24 }
settings.height = nil -- assigning nil REMOVES the key
local keys = {}
for key in pairs(settings) do keys[#keys + 1] = key end
print(#keys)const settings = { width: 80, height: 24 };
settings.height = undefined; // the key REMAINS, holding undefined
console.log(Object.keys(settings).length);
delete settings.height; // this removes it
console.log(Object.keys(settings).length);Setting a property to
undefined leaves it present and enumerable, so it still shows up in Object.keys and in a spread. Removing it takes the delete operator. This asymmetry is a common source of surprise when a Lua habit is carried over.Use === , not ==
Lua's
== never coerces: values of different types are simply unequal, so "1" == 1 is false. JavaScript has two equality operators, and the one that looks familiar is the coercing one.print(1 == 1)
print("1" == 1) -- false: Lua does NOT coerce for ==
print(nil == false) -- falseconsole.log(1 === 1);
console.log("1" === 1); // false
console.log("1" == 1); // TRUE: == coerces before comparing
console.log(null == undefined); // true under ==, false under ===Lua's
== corresponds to JavaScript's ===, and that is the one to write by default. The looser == has exactly one idiomatic use, testing value == null to catch both empty values at once.The or-default idiom, and where it breaks
The
or fallback is the standard Lua way to give a parameter a default, and it works because only nil and false can trigger it. Ported literally to JavaScript with ||, it also swallows 0 and "".local function greet(name, greeting)
greeting = greeting or "Hello"
return greeting .. ", " .. name
end
print(greet("Ada"))
print(greet("Ada", "Welcome"))function greet(name, greeting = "Hello") { // a real default
return greeting + ", " + name;
}
console.log(greet("Ada"));
console.log(greet("Ada", "Welcome"));
const count = 0;
console.log(count || 10); // 10 -- the || idiom eats a valid zero
console.log(count ?? 10); // 0 -- ?? only falls through for null/undefinedJavaScript has two better answers. Default parameters apply when the argument is
undefined, and the nullish coalescing operator ?? falls through only for null and undefined — which is precisely the semantics Lua's or has, restored.Numbers
Back to one number type
Lua 5.3 split numbers into integer and float subtypes. JavaScript did not: every ordinary number is a double, so
7 and 7.0 are the same value and there is no // operator.print(math.type(7)) -- integer
print(math.type(7.0)) -- float
print(7 // 2, 7 / 2) -- 3 3.5console.log(typeof 7); // number -- and that is the whole story
console.log(Number.isInteger(7), Number.isInteger(7.0)); // true true
console.log(Math.floor(7 / 2), 7 / 2); // 3 3.5Number.isInteger asks whether a double happens to have no fractional part, which is a different question from Lua's math.type. If you need Lua's integers, JavaScript's BigInt is the closest thing — and like Lua's subtypes, it arrived late.Where large integers stop being exact
Because Lua has true 64-bit integers and JavaScript has doubles, they part company at 2^53. Above that, an ordinary JavaScript number cannot represent every integer. The Lua column here is shown rather than run — see the note below.
print(math.maxinteger) -- 64-bit integers, exact
print(9007199254740993) -- exact in Lua 5.3+console.log(Number.MAX_SAFE_INTEGER); // 9007199254740991
console.log(9007199254740993); // 9007199254740992 -- rounded!
console.log(9007199254740993n); // exact, with BigIntThe rounding is silent, which makes it dangerous for identifiers — database keys and snowflake IDs are the usual casualties. The
n suffix creates a BigInt, which is exact but cannot be mixed with ordinary numbers in arithmetic without an explicit conversion. 🚨 The Lua column is anchor_norun because this build cannot honestly demonstrate its own point: Fengari uses 32-bit integers, so running it prints 2147483647 and 9007199254741000.0 — Lua appearing to lose precision too, which is the opposite of what the row teaches. The code shown is what a desktop lua prints.Coercion goes further here
Both languages coerce numeric strings, but Lua keeps arithmetic and concatenation on separate operators (
+ and ..) while JavaScript overloads + for both.print("10" + 5) -- 15: Lua coerces the string to a number
print(10 .. 5) -- "105": .. is a separate operatorconsole.log("10" + 5); // "105" -- + CONCATENATES if either side is a string
console.log("10" - 5); // 5 -- but - has no string meaning, so it coerces
console.log(Number("10") + 5); // 15 -- the explicit versionSo
+ is the one arithmetic operator that prefers concatenation, and every other one coerces to a number. That inconsistency is why converting explicitly with Number(…) is the habit worth keeping, exactly as tonumber is the safer spelling in Lua. One browser caveat: Lua 5.3 makes string-to-number coercion produce a float, so Fengari prints 15.0 where a 5.4-or-later desktop lua prints 15. The coercion happens either way; only the subtype differs.NaN, which Lua barely has
Both languages produce a NaN from
0/0, and in both it compares unequal to itself. The difference is how often you meet one.local result = 0 / 0
print(result ~= result) -- true: NaN is not equal to itself here either
print(tonumber("abc")) -- nil, NOT a NaNconst result = 0 / 0;
console.log(result !== result); // true: NaN !== NaN
console.log(Number("abc")); // NaN, not null
console.log(Number.isNaN(Number("abc"))); // the right way to testLua's
tonumber returns nil for unparseable input, which is easy to test. JavaScript's Number returns NaN, which is truthy-adjacent, propagates silently through arithmetic, and needs Number.isNaN to detect. A failed parse therefore travels much further before anyone notices.Strings
Concatenation and interpolation
Lua concatenates with
.. and formats with string.format. JavaScript overloads + for concatenation and adds template literals, which Lua has no equivalent of.local name = "Ada"
print("Hello, " .. name .. "!")
print(string.format("Hello, %s! You are %d.", name, 36))const name = "Ada";
console.log("Hello, " + name + "!");
console.log(`Hello, ${name}! You are ${36}.`);Template literals are the idiomatic choice: they interpolate any expression, span multiple lines without escapes, and avoid the
+-versus-arithmetic ambiguity from the numbers section entirely.Length and indexing
Both store the length rather than computing it, so both are O(1). The indexing conventions are where care is needed: Lua counts from 1 with an inclusive end, JavaScript counts from 0 with an exclusive end.
local text = "hello"
print(#text) -- 5
print(text:sub(1, 1)) -- "h": 1-based, inclusive
print(text:sub(2, 3)) -- "el"const text = "hello";
console.log(text.length); // 5
console.log(text[0]); // "h": 0-based
console.log(text.slice(1, 3)); // "el": start inclusive, end EXCLUSIVEThe translation is not a single offset. Lua's
sub(2, 3) takes characters 2 and 3; the same span in JavaScript is slice(1, 3). Both accept negative indices counting from the end, which is one convenience they genuinely share.Strings are immutable in both
A convergence worth stating plainly: every string operation in both languages returns a new string, and there is no way to modify one in place.
local greeting = "hello"
local shouted = greeting:upper()
print(greeting, shouted) -- the original is untouchedconst greeting = "hello";
const shouted = greeting.toUpperCase();
console.log(greeting, shouted); // the original is untouchedThe method-call syntax matches too — Lua's
greeting:upper() and JavaScript's greeting.toUpperCase() both dispatch through a shared table of string methods. Lua reaches it through the string metatable's __index; JavaScript through String.prototype.Lua patterns become real regular expressions
Lua patterns are a deliberately small, non-backtracking subset invented to avoid shipping a regex engine —
%a, %s, %d, and no alternation. JavaScript has full regular expressions with their own literal syntax.local sentence = "one two three"
for word in sentence:gmatch("%a+") do
io.write(word, ";")
end
print()
print((sentence:gsub("%s+", "-")))const sentence = "one two three";
for (const word of sentence.match(/[a-z]+/g)) {
process.stdout.write(word + ";");
}
console.log();
console.log(sentence.replace(/\s+/g, "-"));The character classes translate (
%a to [a-z] or \w, %s to \s, %d to \d) but the percent sign becomes a backslash, and - means a lazy quantifier in Lua where JavaScript writes *?. Alternation with | and grouping have no Lua equivalent at all.Splitting and joining
Joining exists in both —
table.concat and Array.prototype.join are direct counterparts. Splitting does not: Lua's standard library has no split, and every Lua project eventually writes the gmatch loop on the left.-- Lua has no split; you write it with gmatch.
local parts = {}
for piece in ("a,b,c"):gmatch("[^,]+") do
parts[#parts + 1] = piece
end
print(#parts, table.concat(parts, "|"))const parts = "a,b,c".split(",");
console.log(parts.length, parts.join("|"));This is a fair illustration of the size difference between the two standard libraries. Lua ships about twenty string functions on principle; JavaScript ships several dozen and adds more each year.
Bytes versus UTF-16 code units
A Lua string is a counted sequence of bytes and knows nothing about encoding, so
# on non-ASCII text gives a byte count. A JavaScript string is a sequence of UTF-16 code units, which is a different kind of wrong.local text = "héllo"
print(#text) -- 6: BYTES, not characters
print(utf8.len(text)) -- 5: charactersconst text = "héllo";
console.log(text.length); // 5: UTF-16 code units
console.log([...text].length); // 5: code points
console.log("𝄞".length, [...("𝄞")].length); // 2 and 1 -- they divergeFor text inside the Basic Multilingual Plane the JavaScript count looks right. Outside it — emoji, musical symbols — one character occupies two code units, so
length over-counts. Spreading the string into an array iterates code points and is the closest equivalent to Lua's utf8.len.One Table Type Becomes Two
🚨 One table type becomes two
The single Lua table, doing array and hash-map duty at once, is the deepest structural difference on this page. JavaScript has arrays and objects as separate types, with separate literal syntax and separate method sets.
-- ONE type does both jobs, and can do them at once.
local mixed = { 10, 20, 30, name = "Ada" }
print(#mixed, mixed[1], mixed.name)// Two different types, with different methods.
const list = [10, 20, 30];
const record = { name: "Ada" };
console.log(list.length, list[0], record.name);
// An array IS an object, so this is legal -- and a bad idea.
list.name = "Ada";
console.log(list.length, list.name); // length ignores itAn array is technically an object with numeric keys, so the mixed table on the left has a literal translation — but
length counts only the numeric part and every array method ignores the rest. Split the two roles apart rather than reproducing the Lua shape.Arrays start at 0
Lua is one of the very few languages indexing from 1, and the habit runs deep — it changes the first subscript and the shape of every loop bound.
local values = { "first", "second", "third" }
print(values[1])
for index = 1, #values do
io.write(index, "=", values[index], " ")
end
print()const values = ["first", "second", "third"];
console.log(values[0]);
for (let index = 0; index < values.length; index++) {
process.stdout.write(index + "=" + values[index] + " ");
}
console.log();Because JavaScript arrays are objects, reading past the end gives
undefined rather than an error, exactly as Lua gives nil. So an off-by-one does not crash in either language; it quietly produces an empty value, which is why both communities favor the iteration forms in the next row.ipairs and pairs become two different loops
Lua distinguishes the sequence walk (
ipairs, 1..n, stops at the first hole) from the whole-table walk (pairs, every key, unspecified order). JavaScript splits the same distinction across arrays and objects.local values = { "a", "b" }
for index, value in ipairs(values) do
print(index, value)
end
local record = { x = 1, y = 2 }
for key, value in pairs(record) do
print(key, value)
endconst values = ["a", "b"];
for (const [index, value] of values.entries()) {
console.log(index, value);
}
const record = { x: 1, y: 2 };
for (const [key, value] of Object.entries(record)) {
console.log(key, value);
}The trap is
for…in, which looks like pairs and is not: it walks inherited enumerable properties too, and on an array it yields string indices. Use for…of with entries() for arrays and Object.entries for objects, as above.Length with holes
Lua's
# on a table with a gap is explicitly undefined — the manual says any border may be returned, and the answer depends on the table's internal layout. JavaScript defines it precisely.local values = { 1, 2, 3 }
values[5] = 5 -- a hole at index 4
print(#values) -- 3 or 5: UNDEFINED behavior in Luaconst values = [1, 2, 3];
values[4] = 5; // a hole at index 3
console.log(values.length); // 5, always: length is highest index + 1
console.log(values[3]); // undefinedAn array's
length is always one more than the highest integer index ever assigned, and assigning to it truncates. So the JavaScript answer is predictable where the Lua one is not, which makes sparse arrays merely wasteful here rather than genuinely unsafe.table.insert and table.remove
Lua puts these in the
table library and overloads insert on its argument count. JavaScript makes each one a method with its own name, which is more verbose and considerably easier to read.local values = { "a", "b" }
table.insert(values, "c")
table.insert(values, 1, "start")
print(table.concat(values, ","))
table.remove(values, 1)
print(table.concat(values, ","))const values = ["a", "b"];
values.push("c");
values.unshift("start");
console.log(values.join(","));
values.shift();
console.log(values.join(","));The four names map cleanly:
push and pop at the end, unshift and shift at the front, with splice covering Lua's positional insert/remove in the middle.map and filter, which Lua does not ship
Both languages have first-class functions, so the difference here is library, not capability: JavaScript ships the higher-order array methods and Lua deliberately does not.
-- Lua has no map/filter; you write the loop.
local numbers = { 1, 2, 3, 4 }
local doubled_evens = {}
for _, value in ipairs(numbers) do
if value % 2 == 0 then
doubled_evens[#doubled_evens + 1] = value * 2
end
end
print(table.concat(doubled_evens, ","))const numbers = [1, 2, 3, 4];
const doubledEvens = numbers
.filter((value) => value % 2 === 0)
.map((value) => value * 2);
console.log(doubledEvens.join(","));This is the clearest day-to-day consequence of Lua's minimalism. Lua's whole standard library is smaller than JavaScript's
Array.prototype, which is a design choice — Lua is meant to be embedded, and every kilobyte is paid for in the host binary.Object keys are strings; table keys are anything
A Lua table key can be any value except
nil, and 1 and "1" are distinct keys. A plain JavaScript object converts every key to a string, so those two collide.local lookup = {}
lookup[1] = "number key"
lookup["1"] = "string key" -- a DIFFERENT key
print(lookup[1], lookup["1"])const lookup = {};
lookup[1] = "number key";
lookup["1"] = "string key"; // the SAME key: 1 becomes "1"
console.log(lookup[1], lookup["1"]);
const map = new Map(); // Map keeps key identity, like a Lua table
map.set(1, "number key").set("1", "string key");
console.log(map.get(1), map.get("1"));Map is the structure that behaves like a Lua table here: it preserves key identity, accepts objects and functions as keys, and remembers insertion order. Reach for it whenever the keys are not naturally strings.Control Flow
Conditionals
Structurally identical; only the punctuation moves. JavaScript needs parentheses around the condition and braces instead of
then/end, and writes else if as two words.local score = 72
if score >= 90 then
print("A")
elseif score >= 70 then
print("B")
else
print("C")
endconst score = 72;
if (score >= 90) {
console.log("A");
} else if (score >= 70) {
console.log("B");
} else {
console.log("C");
}Remember from the truthiness section that the condition does not translate as directly as the syntax does — a bare
if (value) means something different here when value can be 0 or "".The and/or idiom becomes a real conditional
Lua has no conditional expression, so
and/or is pressed into service. It works right up until the value you want to select is itself falsy.local ready = true
print(ready and "yes" or "no")
-- The trap: the idiom breaks when the middle value is falsy.
print(true and false or "fallback") -- "fallback", not falseconst ready = true;
console.log(ready ? "yes" : "no");
// A real conditional expression has no such hole.
console.log(true ? false : "fallback"); // falseJavaScript's
? : selects a branch rather than evaluating a boolean chain, so it has no equivalent hole. It is one of the few places where the JavaScript spelling is unambiguously safer than the Lua one.The numeric for loop
Lua's numeric
for takes a start, an inclusive limit and an optional step. JavaScript's is three arbitrary expressions, so the limit and the step are written out.for index = 1, 5 do
io.write(index, " ")
end
print()
for index = 10, 1, -3 do
io.write(index, " ")
end
print()for (let index = 1; index <= 5; index++) {
process.stdout.write(index + " ");
}
console.log();
for (let index = 10; index >= 1; index -= 3) {
process.stdout.write(index + " ");
}
console.log();Writing the test explicitly means the inclusive/exclusive choice is yours:
<= 5 matches Lua here, while the far more common < values.length pairs with zero-based indexing.repeat/until becomes do/while
Both languages have a bottom-tested loop, and their conditions are negations of one another: Lua stops when its condition becomes true, JavaScript continues while its condition is true.
local attempts = 0
repeat
attempts = attempts + 1
until attempts >= 3
print(attempts)let attempts = 0;
do {
attempts += 1;
} while (attempts < 3); // while the condition HOLDS
console.log(attempts);Translating
until attempts >= 3 into while (attempts < 3) requires flipping the test. Forgetting to is a silent infinite loop, which is why it is worth a row of its own.continue is a real keyword
Lua has no
continue, and the community idiom is a goto jumping to a label at the very end of the loop body — the construct on the left.for index = 1, 5 do
if index % 2 == 0 then goto continue end
io.write(index, " ")
::continue::
end
print()for (let index = 1; index <= 5; index++) {
if (index % 2 === 0) continue;
process.stdout.write(index + " ");
}
console.log();JavaScript has the keyword outright, and also supports labeled
break and continue for jumping out of nested loops, which Lua can only approximate with more gotos.Functions
Defining and passing functions
Both treat functions as ordinary values that can be stored in variables, passed as arguments and returned. This is the deepest thing the two languages agree on.
local function add(left, right)
return left + right
end
local apply = function(operation, a, b)
return operation(a, b)
end
print(apply(add, 2, 3))function add(left, right) {
return left + right;
}
const apply = (operation, a, b) => operation(a, b);
console.log(apply(add, 2, 3));The arrow form has no Lua counterpart in syntax, but it is the same idea as
function(…) … end used as an expression. It differs in one respect that matters later: an arrow function does not bind its own this.Multiple returns become an array or object
Genuine multiple return values are a Lua feature JavaScript does not have. The standard replacement is to return an array and destructure it at the call site, which reads almost the same.
local function bounds(numbers)
local smallest, largest = numbers[1], numbers[1]
for _, value in ipairs(numbers) do
if value < smallest then smallest = value end
if value > largest then largest = value end
end
return smallest, largest
end
local low, high = bounds({ 4, 1, 9 })
print(low, high)function bounds(numbers) {
return [Math.min(...numbers), Math.max(...numbers)];
}
const [low, high] = bounds([4, 1, 9]);
console.log(low, high);Returning an object instead —
return { low, high }, destructured as const { low, high } = … — is often better, because the caller names the fields rather than relying on position. Lua's ordering has no such escape.Varargs become rest parameters
Lua's
... is a value list that has to be packed into a table before you can iterate it, and counted with select("#", ...). JavaScript's rest parameter arrives as a real array already.local function sum(...)
local total = 0
for _, value in ipairs({ ... }) do total = total + value end
return total, select("#", ...)
end
print(sum(1, 2, 3))function sum(...values) {
const total = values.reduce((running, value) => running + value, 0);
return [total, values.length];
}
console.log(...sum(1, 2, 3));That is a genuine simplification: no packing step, and
length answers the count directly. The spread operator ... in argument position is the inverse, matching Lua's table.unpack.Closures work the same way
A rare row where nothing needs translating but the keywords. Both languages capture the variable itself rather than a copy, both keep it alive after the enclosing function returns, and both use that as the primary tool for private state.
local function make_counter()
local count = 0
return function()
count = count + 1
return count
end
end
local next_value = make_counter()
print(next_value(), next_value(), next_value())function makeCounter() {
let count = 0;
return function () {
count += 1;
return count;
};
}
const nextValue = makeCounter();
console.log(nextValue(), nextValue(), nextValue());Lua calls the captured variable an upvalue and JavaScript calls it a closed-over binding; the semantics are the same. Note the naming convention does change — Lua uses
snake_case and JavaScript camelCase, which is the only difference visible above.The options-table idiom is idiomatic in both
Lua has no named arguments, so passing a single table of options is the community answer. JavaScript arrived at exactly the same convention, and then added syntax for it.
local function configure(options)
local width = options.width or 80
local height = options.height or 24
print(width, height)
end
configure({ width = 100 })function configure({ width = 80, height = 24 } = {}) {
console.log(width, height);
}
configure({ width: 100 });Destructuring in the parameter list names the fields and supplies defaults in one place, so the body does not repeat the
or fallbacks. The trailing = {} is what lets the whole argument be omitted.Extra and missing arguments are both tolerated
Another convergence: neither language checks the argument count. Missing parameters take the empty value and extra arguments are silently dropped.
local function greet(name, greeting)
print(name, greeting)
end
greet("Ada") -- greeting is nil
greet("Ada", "Hi", "extra") -- the extra is discardedfunction greet(name, greeting) {
console.log(name, greeting);
}
greet("Ada"); // greeting is undefined
greet("Ada", "Hi", "extra"); // the extra is discardedJavaScript keeps the discarded arguments reachable through the
arguments object in a non-arrow function, which Lua has no counterpart to — though rest parameters have made it largely obsolete.self Is a Parameter; this Is Not
The colon call becomes a dot call
Lua's colon is pure sugar:
counter:increment() is counter.increment(counter), and self is an ordinary first parameter the syntax fills in. JavaScript has no such rewrite — this is bound by the call.local counter = { count = 0 }
function counter:increment() -- colon: self is an implicit parameter
self.count = self.count + 1
end
counter:increment() -- colon: passes counter as self
print(counter.count)const counter = {
count: 0,
increment() { // shorthand method syntax
this.count += 1;
},
};
counter.increment(); // the receiver is whatever is left of the dot
console.log(counter.count);That distinction is invisible while you write
object.method() and becomes the next three rows the moment you do anything else with the function.🚨 Detaching a method loses this
This is the single most confusing difference for a Lua programmer, because Lua makes the receiver visible and JavaScript hides it. Pulling a method out of its table is harmless in Lua —
self was always just an argument.local counter = { count = 0 }
function counter:increment() self.count = self.count + 1 end
-- Detaching in Lua is explicit: you must pass self yourself.
local increment = counter.increment
increment(counter) -- works, because self is just a parameter
print(counter.count)const counter = {
count: 0,
increment() { this.count += 1; },
};
const increment = counter.increment;
// increment(); <- TypeError: 'this' is undefined in strict mode
increment.call(counter); // you must supply the receiver
console.log(counter.count);In JavaScript the receiver comes from the call expression, so a detached function has none. Passing a method as a callback is the usual way to hit this. The fixes are
call/apply as above, bind, or wrapping it in an arrow function.Arrow functions inherit this
Because a nested Lua function does not inherit
self, the standard fix is to capture it in a local — the local outer = self line. JavaScript had the identical problem and the identical fix, historically written const self = this.local timer = { name = "timer", ticks = {} }
function timer:record()
local outer = self -- the Lua fix: capture it in an upvalue
local callback = function()
outer.ticks[#outer.ticks + 1] = outer.name
end
callback()
end
timer:record()
print(#timer.ticks, timer.ticks[1])const timer = {
name: "timer",
ticks: [],
record() {
const callback = () => { // an arrow keeps the enclosing 'this'
this.ticks.push(this.name);
};
callback();
},
};
timer.record();
console.log(timer.ticks.length, timer.ticks[0]);Arrow functions solved it at the language level: they have no
this of their own and use the enclosing scope's, so the capture is unnecessary. This is the main reason to prefer an arrow for a callback and a regular function for a method.class is syntax over the same machinery
Lua has no class syntax, so the constructor-plus-metatable pattern on the left is written out in every Lua codebase. JavaScript's
class is sugar over its prototype system — the same underlying mechanism, with a name.local Animal = {}
Animal.__index = Animal
function Animal.new(name)
return setmetatable({ name = name }, Animal)
end
function Animal:speak()
return self.name .. " makes a sound"
end
print(Animal.new("Rex"):speak())class Animal {
constructor(name) {
this.name = name;
}
speak() {
return this.name + " makes a sound";
}
}
console.log(new Animal("Rex").speak());The correspondence is exact:
Animal.__index = Animal is what Animal.prototype already is, setmetatable is what new does, and Animal:speak is a method on the prototype. Having the syntax mostly means everyone writes the pattern the same way.Inheritance
Both languages resolve a missing member by following a chain, so inheritance is the same idea in both. The difference is how much of the chain you have to build yourself.
local Animal = {}
Animal.__index = Animal
function Animal.new(name) return setmetatable({ name = name }, Animal) end
function Animal:speak() return self.name .. " makes a sound" end
local Dog = setmetatable({}, { __index = Animal })
Dog.__index = Dog
function Dog.new(name) return setmetatable(Animal.new(name), Dog) end
function Dog:speak() return self.name .. " barks" end
print(Dog.new("Rex"):speak())class Animal {
constructor(name) { this.name = name; }
speak() { return this.name + " makes a sound"; }
}
class Dog extends Animal {
speak() { return this.name + " barks"; }
}
console.log(new Dog("Rex").speak());The Lua version needs two
setmetatable calls and an __index on each level, and getting one wrong produces a silent nil rather than an error. extends does the same wiring, and super.speak() reaches the parent method, which in Lua means calling Animal.speak(self) directly.Metatables vs. Prototypes
__index and the prototype chain
This is the mechanism both languages built objects out of: a lookup that fails on the value itself falls through to another value. Lua spells it
__index on a metatable; JavaScript calls it the prototype.local defaults = { color = "black" }
local pen = setmetatable({}, { __index = defaults })
print(pen.color) -- black: found on the fallback
print(rawget(pen, "color")) -- nil: not on the object itselfconst defaults = { color: "black" };
const pen = Object.create(defaults);
console.log(pen.color); // black
console.log(Object.hasOwn(pen, "color")); // false
console.log(Object.getPrototypeOf(pen) === defaults); // trueThe parallel extends to introspection. Lua's
rawget reads a key without consulting __index, which is exactly Object.hasOwn, and getmetatable corresponds to Object.getPrototypeOf. The one structural difference is that Lua's metatable is a separate table holding the hooks, while a JavaScript prototype IS the fallback object.__newindex becomes a Proxy
Lua's
__newindex intercepts assignment to a key the table does not already have, which is how read-only tables and change tracking are built. Prototypes cannot do this — JavaScript needed a separate mechanism.local readonly = setmetatable({}, {
__index = { value = 1 },
__newindex = function()
error("read-only table", 0)
end,
})
print(readonly.value)
print(pcall(function() readonly.value = 2 end))const readonly = new Proxy({ value: 1 }, {
set() { throw new Error("read-only object"); },
});
console.log(readonly.value);
try {
readonly.value = 2;
} catch (error) {
console.log(false, error.message);
}Proxy is that mechanism, and it is strictly more powerful: it can trap reads, writes, deletion, key enumeration and function calls. It also arrived in 2015, roughly twenty years after Lua had __newindex.Operator overloading, which JavaScript lacks
Lua lets a metatable redefine arithmetic, comparison, concatenation, indexing, calling and length. JavaScript has nothing equivalent — operators cannot be given new meanings for your types.
local Vector = {}
Vector.__index = Vector
Vector.__add = function(left, right)
return setmetatable({ x = left.x + right.x }, Vector)
end
local sum = setmetatable({ x = 1 }, Vector) + setmetatable({ x = 2 }, Vector)
print(sum.x)class Vector {
constructor(x) { this.x = x; }
add(other) { // a method: there is no operator hook
return new Vector(this.x + other.x);
}
}
const sum = new Vector(1).add(new Vector(2));
console.log(sum.x);The nearest things are
Symbol.toPrimitive and valueOf, which only control how an object converts to a number or string before a built-in operator runs. Anything richer becomes a named method, as above.__tostring becomes toString
Both languages let a value describe itself, and both consult that hook when converting to a string. The names differ but the role is identical.
local Point = {}
Point.__index = Point
Point.__tostring = function(self)
return "Point(" .. self.x .. ", " .. self.y .. ")"
end
local point = setmetatable({ x = 3, y = 4 }, Point)
print(tostring(point))class Point {
constructor(x, y) { this.x = x; this.y = y; }
toString() {
return "Point(" + this.x + ", " + this.y + ")";
}
}
const point = new Point(3, 4);
console.log(String(point));One practical difference: Lua's
print calls tostring and so honors __tostring, whereas console.log shows an object's structure rather than calling toString. That is why the example uses String(point) to force it.Callable tables and callable objects
Lua needs the
__call metamethod to make a table callable, because tables and functions are different types. JavaScript needs nothing: a function is already an object and can carry properties.local adder = setmetatable({ amount = 10 }, {
__call = function(self, value) return value + self.amount end,
})
print(adder(5))
print(adder.amount) -- still an ordinary table with fields// Functions ARE objects here, so properties go straight on them.
function adder(value) { return value + adder.amount; }
adder.amount = 10;
console.log(adder(5));
console.log(adder.amount);So the pattern Lua reaches a metamethod for is the default state of affairs in JavaScript. This is one of the few places where JavaScript's object model is the simpler of the two.
pcall vs. try/catch
pcall becomes try/catch
Both languages unwind the stack to a handler, so the model is the same. The shape differs: Lua wraps the risky code in a function and gets a status back, JavaScript uses a statement.
local ok, message = pcall(function()
error("something broke", 0)
end)
print(ok, message)
print("execution continues")try {
throw new Error("something broke");
} catch (error) {
console.log(false, error.message);
}
console.log("execution continues");Because
pcall returns rather than branching, Lua code tends to check a boolean where JavaScript code tends to nest a block. JavaScript's finally clause has no direct Lua equivalent — the nearest is <close> variables in 5.4, which Fengari does not implement.Any value can be thrown in both
Neither language restricts what can be raised — a table or object carrying structured information is legal in both, and both communities use it for typed errors.
local ok, thrown = pcall(function()
error({ code = 404, reason = "not found" })
end)
print(ok, thrown.code, thrown.reason)try {
throw { code: 404, reason: "not found" }; // legal, but poor practice
} catch (thrown) {
console.log(false, thrown.code, thrown.reason);
}JavaScript convention is to throw an
Error (or a subclass) anyway, because only those carry a stack trace. Lua has no such class; the equivalent is a table with a __tostring metamethod so the message still reads well if it escapes.Error position information
Lua's
error takes a level that prepends the file and line to the message string itself, so the position becomes part of the text. JavaScript keeps them apart.local ok, message = pcall(function()
error("plain message", 0) -- level 0: no position prefix
end)
print(message)
local ok2, message2 = pcall(function()
error("with position") -- level 1 (default): prefixed
end)
print(message2:find("plain") == nil)try {
throw new Error("plain message");
} catch (error) {
console.log(error.message); // just the message
console.log(typeof error.stack); // the position lives here instead
}An
Error has a message that is exactly what you passed and a separate stack carrying the position. That separation is why JavaScript code can match on a message reliably, while Lua code that does so has to cope with a prefix that varies with the level.assert
Lua's
assert is an ordinary function that returns its first argument when truthy and raises otherwise, which makes it usable inline as well as as a check.local function withdraw(balance, amount)
assert(amount > 0, "amount must be positive")
return balance - amount
end
print(withdraw(100, 30))
print(pcall(withdraw, 100, -5))function withdraw(balance, amount) {
if (!(amount > 0)) throw new Error("amount must be positive");
return balance - amount;
}
console.log(withdraw(100, 30));
try {
withdraw(100, -5);
} catch (error) {
console.log(false, error.message);
}JavaScript has no built-in
assert in the language — Node ships one in a module, and browser code throws directly, as above. Note the guard is written !(amount > 0) rather than amount <= 0 so that a NaN amount also fails.Coroutines vs. Generators & async
Coroutines become generators
Generators are the closest JavaScript construct to a coroutine: a function that can suspend itself mid-body and be resumed. The correspondence between
coroutine.yield and yield is exact.local function counter()
for index = 1, 3 do
coroutine.yield(index)
end
end
local routine = coroutine.create(counter)
print(select(2, coroutine.resume(routine)))
print(select(2, coroutine.resume(routine)))
print(select(2, coroutine.resume(routine)))function* counter() {
for (let index = 1; index <= 3; index++) {
yield index;
}
}
const routine = counter();
console.log(routine.next().value);
console.log(routine.next().value);
console.log(routine.next().value);The differences are in the wrapping. A generator is declared with
function* rather than created from any function, calling it returns the object rather than starting it, and next() returns a { value, done } record where coroutine.resume returns a status plus the yielded values.Passing values back in
Both are two-way channels: the value passed to the resume call becomes the result of the suspended
yield expression. This is what makes them coroutines rather than mere iterators.local function echo()
local received = coroutine.yield("ready")
coroutine.yield("got " .. received)
end
local routine = coroutine.create(echo)
print(select(2, coroutine.resume(routine)))
print(select(2, coroutine.resume(routine, "hello")))function* echo() {
const received = yield "ready";
yield "got " + received;
}
const routine = echo();
console.log(routine.next().value);
console.log(routine.next("hello").value);The mechanism is identical down to the detail that the first resume's argument is discarded, because there is no suspended
yield waiting to receive it yet. A Lua programmer needs to learn no new concept here, only new spelling.Generators plug into for…of
Lua's
coroutine.wrap turns a coroutine into a plain function that returns the next value each call — which is exactly the shape a generic for loop wants. JavaScript generators satisfy the iteration protocol directly.local function range(limit)
return coroutine.wrap(function()
for index = 1, limit do coroutine.yield(index) end
end)
end
for value in range(3) do
io.write(value, " ")
end
print()function* range(limit) {
for (let index = 1; index <= limit; index++) yield index;
}
for (const value of range(3)) {
process.stdout.write(value + " ");
}
console.log();So both languages use the same trick to make a suspendable function drive a loop. The JavaScript version needs no wrapper because a generator object is already iterable, and it also stops the loop cleanly when the function returns rather than yielding
nil.🚨 async/await is not a coroutine
This is the row where the family resemblance is most misleading.
await looks like coroutine.yield and behaves quite differently, because JavaScript has a scheduler and Lua does not.-- Lua coroutines are SYNCHRONOUS: resume runs the body immediately
-- and control returns when it yields. There is no scheduler, no queue,
-- and nothing runs between your statements.
local routine = coroutine.create(function()
coroutine.yield("first")
return "second"
end)
print(select(2, coroutine.resume(routine)))
print(select(2, coroutine.resume(routine)))
print("this line runs LAST")// await suspends the function and returns control to the EVENT LOOP.
(async () => {
const value = await Promise.resolve("first");
console.log(value);
console.log("this line runs after the microtask queue drains");
})();
console.log("this line runs FIRST, before the await resumes");Resuming a Lua coroutine runs its body right now, and nothing else can run in between. An
await hands control back to the event loop, which may run any amount of other work before resuming. The printed order above is the proof: the last line of the JavaScript column runs first.Promises have no Lua counterpart
Lua's standard library has no concurrency primitive at all — coroutines are a control-flow tool, and any scheduling is something the host or your own code provides, as the hand-rolled queue on the left shows.
-- Lua has no promise, no event loop and no built-in timer:
-- concurrency in a Lua host comes from the host (LÖVE, OpenResty,
-- nginx), never from the standard library. A scheduler is written
-- by hand on top of coroutines.
local queue = {}
local function schedule(fn) queue[#queue + 1] = coroutine.create(fn) end
schedule(function() print("task one") end)
schedule(function() print("task two") end)
for _, routine in ipairs(queue) do coroutine.resume(routine) end(async () => {
const results = await Promise.all([
Promise.resolve("task one"),
Promise.resolve("task two"),
]);
for (const result of results) console.log(result);
})();JavaScript builds the scheduler into the runtime and exposes it as promises, so composition operators like
Promise.all come for free. Porting Lua code that assumes a synchronous world is usually the hardest part of the move.Inspecting a suspended routine
Lua exposes a coroutine's state directly through
coroutine.status, which reports suspended, running, normal or dead. JavaScript has no status function.local routine = coroutine.create(function()
coroutine.yield()
end)
print(coroutine.status(routine)) -- suspended
coroutine.resume(routine)
print(coroutine.status(routine)) -- suspended (at the yield)
coroutine.resume(routine)
print(coroutine.status(routine)) -- deadfunction* routineBody() { yield; }
const routine = routineBody();
console.log(routine.next().done); // false: yielded
console.log(routine.next().done); // true: finished
console.log(routine.next().done); // true: still finished, no errorThe
done flag on each result is all you get, and it is the only distinction that matters in practice. Resuming an exhausted generator is harmless and simply reports done again, whereas coroutine.resume on a dead coroutine returns false with an error message.require Both Ways
A module is a value it returns
The model is the same in both: a module is a file that produces a value, and requiring it gives you that value. Lua returns it; JavaScript assigns it to a designated name.
-- A Lua module file ends by returning a table.
local geometry = {}
function geometry.area(width, height) return width * height end
-- return geometry
print(geometry.area(3, 4))// A CommonJS module assigns to module.exports; ESM uses 'export'.
const geometry = {};
geometry.area = (width, height) => width * height;
// module.exports = geometry; (CommonJS)
// export default geometry; (ESM)
console.log(geometry.area(3, 4));The export lines are commented out because this page's runner evaluates each example as a standalone script — a real module file would end with one of them. Note that JavaScript has two module systems, the older CommonJS
require and the standard import, and they interoperate imperfectly.Modules are cached in both
Both languages run a module once and cache the result, so a second
require returns the same value rather than re-executing the file. Both also expose the cache as an ordinary data structure.-- package.loaded is the cache, and it is an ordinary table
-- you can inspect and even modify.
print(type(package.loaded))
print(package.loaded["string"] ~= nil) -- already loaded
print(type(package.path)) -- the run-time search path// require.cache is the CommonJS equivalent, also inspectable.
console.log(typeof require.cache);
console.log(require.resolve("path").length > 0); // resolves without loading
console.log(Array.isArray(module.paths)); // the search pathThis parallel is close enough that the hot-reloading trick is the same in both: delete the entry from the cache and require again. Standard ES modules are the exception — their bindings are resolved statically and there is no supported way to evict one.
Destructuring an import
Lua has no import syntax at all —
require is an ordinary function returning an ordinary value, and pulling pieces out of it is manual assignment.-- Lua pulls fields off the returned table by hand.
local string_library = require("string")
local format, upper = string_library.format, string_library.upper
print(format("%s!", upper("hi")))// Destructuring does the same job in one line.
const { format } = require("util");
console.log(format("%s!", "HI"));
// ESM spells it: import { format } from "util";JavaScript's destructuring makes the same operation a single line, and the ESM
import { … } form additionally lets the tooling see which names are used, which is what makes tree-shaking possible. Lua's dynamic require gives a bundler nothing to analyze.Gotchas for Lua Developers
A zero-length check inverts
A concrete instance of the truthiness rule, singled out because it is the shape that appears most often in real code and reads as correct in both languages.
local items = {}
if #items then
print("this ALWAYS runs in Lua: 0 is truthy")
endconst items = [];
if (items.length) {
console.log("never runs: 0 is falsy");
} else {
console.log("this runs in JavaScript");
}In Lua the guard is meaningless —
#items is a number and every number is truthy, so the branch always runs. In JavaScript the same expression is the idiomatic emptiness test. Code ported in either direction changes behavior silently.sort compares as strings by default
Lua's
table.sort uses <, so a table of numbers sorts numerically. JavaScript's default converts every element to a string first.local numbers = { 10, 9, 100 }
table.sort(numbers) -- numeric by default
print(table.concat(numbers, ","))const numbers = [10, 9, 100];
numbers.sort(); // "10", "100", "9" -- STRING order
console.log(numbers.join(","));
numbers.sort((left, right) => left - right); // the fix
console.log(numbers.join(","));So
[10, 9, 100].sort() gives 10, 100, 9. A comparator is required for numbers, and it returns a negative, zero or positive number rather than the boolean Lua's optional comparator expects — passing a boolean-returning function silently produces a wrong order.Both copy references, not contents
A convergence rather than a difference, included because it bites often: assignment copies a reference in both languages, and neither has a deep copy in its standard library.
local original = { 1, 2, 3 }
local alias = original -- the same table
alias[1] = 99
print(original[1]) -- 99
local copy = { table.unpack(original) }
copy[1] = 1
print(original[1], copy[1])const original = [1, 2, 3];
const alias = original; // the same array
alias[0] = 99;
console.log(original[0]); // 99
const copy = [...original]; // a shallow copy
copy[0] = 1;
console.log(original[0], copy[0]);The spread form is JavaScript's
table.unpack idiom and is equally shallow — nested tables and objects are still shared. For a genuine deep copy JavaScript now has structuredClone, which Lua has no counterpart to at all.Numeric keys become strings on an object
The key-type rule from the tables section, in the form that actually causes a bug: building a lookup keyed by an identifier that is sometimes a number and sometimes its string form.
local counts = {}
counts[1] = "one"
counts["1"] = "string one"
local total = 0
for _ in pairs(counts) do total = total + 1 end
print(total) -- 2: distinct keysconst counts = {};
counts[1] = "one";
counts["1"] = "string one";
console.log(Object.keys(counts).length); // 1: they collided
console.log(counts[1]); // "string one"In Lua the two entries coexist and the table quietly grows two records for one logical key. In JavaScript they overwrite each other. Both outcomes are wrong and neither is reported — use a
Map, or normalize the key before storing it.Object key order is specified; table order is not
Lua makes no promise about the order
pairs visits keys, and it can differ between runs and between versions. JavaScript specifies object key order exactly.-- pairs() order is explicitly UNSPECIFIED and may vary.
local record = { b = 2, a = 1, c = 3 }
local keys = {}
for key in pairs(record) do keys[#keys + 1] = key end
table.sort(keys) -- sort to get a stable answer
print(table.concat(keys, ","))// Insertion order is guaranteed for string keys.
const record = { b: 2, a: 1, c: 3 };
console.log(Object.keys(record).join(",")); // b,a,c -- alwaysInteger-like keys come first in ascending order, then string keys in insertion order, then symbols. Relying on that is safe in JavaScript and unsafe in Lua — which means code moving from JavaScript to Lua is at greater risk here than the other way around.