Part of Learn Lua with NeoVim

Lua with Neovim: Type Conversion — Automatic Coercion, Safe Defaults & Explicit Casts | Episode 28

Sandy LaneSandy Lane

Video: Lua with Neovim: Type Conversion — Automatic Coercion, Safe Defaults & Explicit Casts | Episode 28 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Lua Type Conversion: Coercion, tonumber, tostring, and Safe Defaults

Lua coerces "10" + 5 to 15 automatically — but only between strings and numbers. tonumber("abc") returns nil for bad input. string.format for controlled formatting.

Lua does some automatic type conversion (called coercion) and leaves the rest to you. Today we cover the rules and the standard idioms for safe conversions.

Automatic coercion: arithmetic on strings

print(10 + 5)           -- 15
print("10" + 5)         -- 15 (string "10" coerced to number)
print("3.14" * 2)       -- 6.28

When you use an arithmetic operator on a string that looks like a number, Lua converts it for you. Convenient but occasionally surprising.

Coercion failure

local ok, err = pcall(function()
  local x = "hello" + 5
end)
print("err: " .. err)
-- "errors.lua:N: attempt to add a 'string' with a 'number'"

If the string can't be parsed as a number, the operation errors. Coercion isn't "do something reasonable" — it's "convert if the string is numeric, else error."

Concatenation also coerces

print("score: " .. 42)        -- "score: 42"
print("pi is " .. 3.14)       -- "pi is 3.14"
print("flag: " .. true)       -- ERROR! booleans don't coerce

.. converts numbers to strings automatically. But it does not convert booleans, tables, or nil — for those, use tostring(...):

print("flag: " .. tostring(true))   -- "flag: true"
print("val: " .. tostring(nil))     -- "val: nil"

This asymmetry trips people up. The rule: .. and arithmetic coerce between string and number only.

tonumber: explicit conversion

print(tonumber("42"))       -- 42
print(tonumber("3.14"))     -- 3.14
print(tonumber("abc"))      -- nil (not a number)
print(tonumber("0x1F"))     -- 31 (hex)
print(tonumber("1e3"))      -- 1000.0 (scientific)

tonumber(s) returns the number, or nil if the string isn't a valid number representation.

The two-argument form takes a base:

print(tonumber("FF", 16))   -- 255
print(tonumber("101", 2))   -- 5 (binary)
print(tonumber("17", 8))    -- 15 (octal)

Bases from 2 to 36.

The safe-default pattern

local input1 = "25"
local input2 = "oops"

local val1 = tonumber(input1) or 0   -- 25
local val2 = tonumber(input2) or 0   -- 0 (fallback)

print(val1)   -- 25
print(val2)   -- 0

tonumber(...) or default is the standard "convert with fallback" idiom. If the input is bad, nil falls back to the default.

This works because nil is falsy, and or returns its second operand when the first is falsy. (Episode 8.)

For strict validation, check explicitly:

local n = tonumber(input)
if n == nil then
  print("Invalid number")
else
  print("Got: " .. n)
end

tostring: any value to a string

print(tostring(42))      -- "42"
print(tostring(true))    -- "true"
print(tostring(nil))     -- "nil"
print(tostring({}))      -- "table: 0x..." (memory address)

tostring(value) always returns a string. For tables, the result is the memory address — not useful for inspection, but at least it's a string.

For a custom string representation of a table, define __tostring in its metatable (episode 29).

Coercion reaches deep

local sum = "1" + "2" + "3"
print(sum)   -- 6

All three strings coerce. This works because + doesn't care about source type — only that both operands can be converted.

But:

-- ERROR: strings concatenate when used with ..
print("1" .. "2" .. "3")   -- "123"

.. doesn't coerce strings to numbers; it concatenates. The operator drives the behaviour.

Comparisons don't coerce (mostly)

print(1 == "1")        -- false (different types)
print(1 == 1.0)        -- true (number == number)
print("a" == "a")      -- true

== does not coerce. 1 ~= "1" even though "1" + 0 == 1. JavaScript's loose == famously coerces; Lua's doesn't.

Order comparisons (<, >, <=, >=) error on mixed types:

print(1 < "2")   -- ERROR: attempt to compare number with string

Convert explicitly when comparing user input.

string.format: controlled number-to-string

local pi = 3.14159265
print(string.format("Default: %f", pi))      -- "3.141593"
print(string.format("2 decimals: %.2f", pi)) -- "3.14"
print(string.format("No decimals: %.0f", pi))-- "3"
print(string.format("Padded: %010.2f", pi))  -- "0000003.14"
print(string.format("Price: $%.2f", 9.9))    -- "$9.90"

string.format gives precise control. Format specifiers:

  • %d — integer.
  • %f — float, default 6 decimals.
  • %.Nf — float with N decimals.
  • %Wd — integer with width W (right-padded with spaces).
  • %-Wd — left-aligned.
  • %0Wd — zero-padded.
  • %e — scientific notation.
  • %g — shorter of %f and %e.

For currency, padding, scientific output — always use string.format.

Numeric strings vs numbers in keys

local t = {}
t[1] = "one"
t["1"] = "string one"
print(t[1])     -- "one"
print(t["1"])   -- "string one"

1 and "1" are different keys even though they look similar. Lua doesn't normalise.

This bites when reading data from JSON/etc. — sometimes a key looks like a number but is a string. Convert explicitly when needed.

Common stumbles

Concatenating a boolean. "flag: " .. true errors. Use tostring(true).

Comparing a number to a string. n == "10" is always false (different types). Use tonumber or tostring to align them.

Forgetting tonumber on io.read. io.read() returns a string; 2026 - io.read() errors. tonumber(io.read()) works.

Trusting tostring on tables. It returns the memory address. For real inspection, write a printer or use __tostring.

Implicit-coerce-then-strict-compare. ("10" + 0) == 10 is true. "10" == 10 is false. Subtle.

What's next

Episode 29: metatables. The mechanism behind operator overloading, custom tostring, default-value tables, and (eventually) OOP. setmetatable(t, mt) to attach metamethods.

Recap

Lua coerces strings ↔ numbers for + - * / // etc., and .. converts numbers to strings. Booleans, nil, tables don't coerce — use tostring. tonumber(s) returns the number or nil; tonumber(s) or default is the safe pattern. == does not coerce; 1 ~= "1". string.format for controlled output (decimals, padding, currency).

Next episode: metatables.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.