Polyglot
Languages Lua types
Lua § types

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 local for almost everything — globals are conventionally avoided.
  • Use the or operator for default values (with caveats for false/nil).
  • Use type() for type checks; math.type() for integer/float (5.3+).
  • Use tostring / tonumber for explicit conversion.
  • Avoid nil holes 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 assert and 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.