Lua with Neovim: Type Conversion — Automatic Coercion, Safe Defaults & Explicit Casts | Episode 28
Video: Lua with Neovim: Type Conversion — Automatic Coercion, Safe Defaults & Explicit Casts | Episode 28 by Taught by Celeste AI - AI Coding Coach
Lua Type Conversion: Coercion, tonumber, tostring, and Safe Defaults
Lua coerces
"10" + 5to15automatically — but only between strings and numbers.tonumber("abc")returnsnilfor bad input.string.formatfor 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%fand%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.