Types
Lua is dynamically typed — values carry their type at runtime; variables do not have declared types. The language has eight basic types: nil, boolean, number, string, function, table, userdata, and thread (coroutine). Since Lua 5.3, number admits two subtypes — integer (64-bit signed) and float (double-precision) — formerly all numbers were floats. Tables are the single composite type — admit arrays, hashes, objects, and modules. userdata admits embedding host (C/C++) data; thread is the coroutine type. The combination — eight types, the table as universal composite, the integer/float distinction, the dynamic typing — is the substance of Lua’s type system.
The eight types
print(type(nil)) -- "nil"
print(type(true)) -- "boolean"
print(type(42)) -- "number"
print(type("hello")) -- "string"
print(type(print)) -- "function"
print(type({})) -- "table"
print(type(io.stdout)) -- "userdata"
print(type(coroutine.create(function() end))) -- "thread"
The type() function returns the type as a string.
nil
The nil value represents the absence of a useful value:
local x = nil
local y -- implicitly nil
print(x) -- "nil"
print(type(x)) -- "nil"
Variables not assigned have value nil; reading non-existent table fields returns nil:
local t = {}
print(t.foo) -- nil (no such key)
local function f()
-- no return; implicit return nothing
end
print(f()) -- (no output; multiple returns of nothing)
local x = f() -- x is nil
boolean
Two values: true and false. These are the only boolean values; all other values are truthy (including 0, "", {}).
local active = true
local done = false
if active then
print("active")
end
number
Since Lua 5.3, the number type admits two subtypes:
local n = 42 -- integer (subtype)
local f = 3.14 -- float (subtype)
math.type(42) -- "integer"
math.type(3.14) -- "float"
type(42) -- "number" (unified type)
type(3.14) -- "number"
The integer subtype admits exact arithmetic up to math.maxinteger (2^63 - 1); the float subtype is IEEE 754 double-precision.
For forced float:
local f = 1.0 -- float (decimal point)
local f = 1e0 -- float (exponent)
For forced integer (5.3+):
local n = 1 // 1 -- 1 (integer; floor division)
local n = math.tointeger(1.0) -- 1 (integer or nil)
Pre-5.3 numbers
In Lua 5.1 and 5.2 (and LuaJIT), all numbers were floats:
-- Pre-5.3 / LuaJIT:
print(type(42)) -- "number"
-- No integer/float distinction
The integer/float distinction is conventional in modern Lua (5.3+); LuaJIT and 5.1 admit only float arithmetic.
Numeric literals
42 -- integer (5.3+)
3.14 -- float
1e3 -- float (1000.0)
0x1A -- hex integer (26)
0x1.8p+1 -- hex float (3.0)
The 0x prefix admits hexadecimal; the e/E admit scientific notation; the 0x...p admits hex floating-point literals (rare).
string
Strings are immutable sequences of bytes:
local s = "hello"
local s = 'world' -- single quotes admitted
local s = [[multi-line
string]] -- long bracket form
Strings admit .. for concatenation and # for length:
local s = "hello" .. " " .. "world" -- "hello world"
print(#s) -- 11
Treated in Strings.
table
The single composite type — admits arrays, hashes, records, modules, classes:
local t = {} -- empty table
local arr = {10, 20, 30} -- array (1-indexed)
local map = {name = "Alice", age = 30} -- hash
local mixed = {10, 20, name = "list"} -- both
print(arr[1]) -- 10 (first; 1-indexed!)
print(map.name) -- "Alice"
print(map["name"]) -- "Alice" (equivalent)
Tables are mutable and reference-typed — assignment shares; modification through one binding is visible through others.
Treated in Tables.
function
Functions are first-class values:
local f = function(x) return x * 2 end
local g = f -- shared
print(f == g) -- true (same function)
print(type(f)) -- "function"
-- Pass as argument:
table.sort(people, function(a, b) return a.age < b.age end)
-- Multiple returns:
local function divmod(a, b)
return a // b, a % b
end
local q, r = divmod(17, 5)
Treated in Functions and closures.
userdata
The userdata type admits embedding host (C/C++) data in Lua values:
local file = io.open("data.txt", "r") -- file is userdata
print(type(file)) -- "userdata"
Two subtypes:
- Full userdata — admit metatables, conventional in C extensions.
- Light userdata — admit C pointers without GC.
The conventional contemporary use cases are file handles, network sockets, host-application objects in embedded contexts.
thread (coroutine)
The thread type admits coroutines:
local co = coroutine.create(function()
coroutine.yield(1)
coroutine.yield(2)
return 3
end)
print(type(co)) -- "thread"
print(coroutine.resume(co)) -- true, 1
print(coroutine.resume(co)) -- true, 2
print(coroutine.resume(co)) -- true, 3
Treated in Coroutines.
Type checking
type(value) -- "nil", "boolean", "number", etc.
math.type(value) -- "integer", "float", or nil
-- Conventional checks:
if type(x) == "string" then
-- x is a string
end
if x == nil then
-- explicit nil check
end
Type conversions
tostring
tostring(42) -- "42"
tostring(3.14) -- "3.14"
tostring(true) -- "true"
tostring(nil) -- "nil"
tostring({1, 2, 3}) -- "table: 0x..." (memory address)
tonumber
tonumber("42") -- 42
tonumber("3.14") -- 3.14
tonumber("not a number") -- nil
tonumber("0x1A") -- 26
tonumber("1010", 2) -- 10 (binary)
tonumber("FF", 16) -- 255 (hex)
The tonumber returns nil on failure; admits an optional base argument.
tointeger (5.3+)
math.tointeger(42) -- 42
math.tointeger(42.0) -- 42
math.tointeger(42.5) -- nil (not exactly representable)
math.tointeger("42") -- 42
math.tointeger("not a number") -- nil
The math.tointeger admits strict conversion; nil on failure.
Numeric ↔ string coercion
Lua admits implicit numeric coercion in arithmetic:
local n = "10" + 5 -- 15 (implicit conversion)
local n = "abc" + 5 -- error: not a number
-- And in concatenation:
local s = "value: " .. 42 -- "value: 42"
The conventional discipline is explicit conversion via tostring and tonumber.
Reference vs value semantics
-- Strings, numbers, booleans, nil — value semantics:
local a = "hello"
local b = a
b = "world"
print(a) -- "hello" (unchanged)
-- Tables, functions, userdata, threads — reference semantics:
local a = {1, 2, 3}
local b = a
b[1] = 99
print(a[1]) -- 99 (shared)
-- Equality:
local t1 = {1, 2}
local t2 = {1, 2}
print(t1 == t2) -- false (different tables)
local t3 = t1
print(t1 == t3) -- true (same table)
The mechanism distinguishes primitive value types (no shared mutation) from composite reference types (shared mutation possible).
nil vs absent values
A subtle distinction:
local t = {1, 2, nil, 4, 5}
print(#t) -- 5 or 2 (implementation-defined!)
-- # may stop at the first nil "hole":
print(t[1], t[2], t[3], t[4], t[5]) -- 1 2 nil 4 5
-- Iterate with ipairs (stops at first nil):
for i, v in ipairs(t) do
print(i, v) -- 1 1, 2 2 (stops at nil)
end
-- Iterate with pairs (visits all keys):
for k, v in pairs(t) do
print(k, v) -- visits all non-nil keys
end
The conventional defence is to avoid nil holes in array-style tables.
Common patterns
Numeric typing
-- Force integer:
local n = math.floor(x)
local n = x // 1
local n = math.tointeger(x) or 0
-- Force float:
local f = x + 0.0
local f = x * 1.0
Type-based dispatch
local function describe(value)
local t = type(value)
if t == "number" then
return "number: " .. value
elseif t == "string" then
return "string: " .. value
elseif t == "table" then
return "table of " .. #value .. " entries"
elseif t == "function" then
return "a function"
else
return "unknown: " .. t
end
end
Default values
local function greet(name)
name = name or "world" -- default if nil/false
return "Hello, " .. name
end
greet() -- "Hello, world"
greet("Alice") -- "Hello, Alice"
The or operator returns the first truthy operand — substantial for default-value patterns.
Optional return values
local function find(list, target)
for i, v in ipairs(list) do
if v == target then
return i, v -- found: index and value
end
end
return nil -- not found
end
local idx = find({10, 20, 30}, 20)
if idx then
print("found at", idx)
end
Numeric range checks
local function clamp(n, min, max)
if n < min then return min
elseif n > max then return max
else return n
end
end
clamp(5, 0, 10) -- 5
clamp(-1, 0, 10) -- 0
clamp(20, 0, 10) -- 10
Integer vs float arithmetic
-- Integer division (5.3+):
print(7 // 2) -- 3 (integer division)
print(7.0 // 2) -- 3.0 (float because of operand)
print(7 / 2) -- 3.5 (always float)
-- Modulo:
print(7 % 2) -- 1
print(-7 % 2) -- 1 (sign follows the divisor)
String/number coercion (avoid)
-- Avoid:
local n = "10" + 5
-- Prefer:
local n = tonumber("10") + 5
The implicit coercion is admitted but conventionally avoided for clarity.
Empty checks
if not list or #list == 0 then
print("empty or nil")
end
if not str or str == "" then
print("empty or nil string")
end
if not t or next(t) == nil then
print("empty or nil table")
end
The next(t) returns nil for an empty table (more reliable than #t for tables with non-numeric keys).
Validating function arguments
local function process(x)
assert(type(x) == "number", "expected number, got " .. type(x))
assert(x > 0, "expected positive number")
-- ...
end
A note on the conventional discipline
The contemporary Lua type advice:
- Use
localfor almost everything — globals are conventionally avoided. - Use the
oroperator for default values (with caveats forfalse/nil). - Use
type()for type checks;math.type()for integer/float (5.3+). - Use
tostring/tonumberfor explicit conversion. - Avoid
nilholes in array-style tables. - Trust the integer/float distinction (5.3+) — admit substantial precision.
- Use tables for everything composite — there is no other option.
- Use
assertand explicit type checks for validation in public APIs.
The combination — eight basic types, the unified table for all composites, the integer/float subtype distinction, the dynamic typing with substantial coercion, the strict truthiness of nil/false only — is the substance of Lua’s type system. The discipline produces concise, expressive code with substantial flexibility through the universal table type.