Running It, and the Type Layer
Hello, World
The starting point is the same as any dynamic language. What differs is what happens between saving the file and running it: Lua goes straight to the interpreter, TypeScript goes through a compiler first.
print("Hello, World!")console.log("Hello, World!");That compiler produces JavaScript and then throws the types away. Nothing in the output records that you wrote TypeScript, which is the fact the whole page keeps returning to.
Mistakes are caught before anything runs
This is the entire proposition in one row. Lua discovers the mistake when the line executes, which may be in a branch nobody took until production. TypeScript reports it while you are typing.
local function double(value)
return value * 2
end
print(double(21))
-- print(double("21")) -- runs, then fails at run time:
-- attempt to perform arithmetic on a string valuefunction double(value: number): number {
return value * 2;
}
console.log(double(21));
// double("21");
// Argument of type 'string' is not assignable to
// parameter of type 'number'. -- reported BEFORE runningNote that Lua would actually accept
double("21") — it coerces numeric strings — so the failure only appears for a string that is not numeric. That is the sort of thing a type checker removes from the space of possible bugs entirely.Most types are inferred, not written
A common misconception is that TypeScript means annotating everything. In practice most annotations are inferred, and idiomatic TypeScript looks much like the Lua beside it.
local count = 3 -- a number, though nothing says so
local label = "items"
print(count, label)const count = 3; // inferred as 3 (a literal type)
let total = 3; // inferred as number
const label = "items"; // inferred as "items"
console.log(count, total, label);The inference is finer than it first appears:
const infers the literal type 3, while let widens to number because the binding can change. That distinction is what makes the literal and union types later in this page work.Comments
-- A single-line comment.
--[[ A long comment
spanning several lines. ]]
print("commented")// A single-line comment.
/* A block comment
spanning several lines. */
/** A doc comment — the editor shows this on hover. */
console.log("commented");The third form matters more here than in most languages: a
/** … */ doc comment is read by the language server and shown at every call site, alongside the inferred types. Lua has no equivalent that any tool reads by default.Annotating What Lua Leaves Implicit
Where the annotation goes
The annotation follows the name after a colon, which is the same position GDScript and Python use. Lua has no slot for it at all.
local health = 100
local name = "player"
local ratio = 0.5
print(health, name, ratio)let health: number = 100;
const name: string = "player";
const ratio = 0.5; // annotation omitted: inference is enough
console.log(health, name, ratio);Writing
: number on an obvious initializer is noise, and most style guides discourage it. Annotations earn their place on function parameters, return types, and anything whose type the compiler cannot see — which is the pattern the rest of this section follows.The primitive types
Lua has eight types and
type() names them at run time. TypeScript's primitives line up closely — number, string, boolean — and then split Lua's single nil in two.print(type(1), type("a"), type(true), type(nil))
print(type({}), type(print))const values: [number, string, boolean, null, undefined] =
[1, "a", true, null, undefined];
console.log(values.map((value) => typeof value).join(" "));
console.log(typeof {}, typeof console.log);The one that surprises:
typeof null is "object", a JavaScript bug preserved for compatibility. TypeScript's static type of null is correct even though the runtime answer is not, which is a neat illustration of the two systems being genuinely separate.any turns the checking off; unknown does not
A Lua variable is
any by nature — it holds whatever and nothing is verified. TypeScript can reproduce that exactly with any, and that is usually the wrong tool.-- Every Lua value is effectively 'any': nothing is checked.
local value = "text"
value = 42
value = {}
print(type(value))let loose: any = "text";
loose = 42;
// loose.whatever.deeply.nested;
// COMPILES — 'any' disables checking — and then throws at run time:
// TypeError: Cannot read properties of undefined (reading 'deeply')
console.log(typeof loose);
let safe: unknown = "text";
// safe.length; Error: 'safe' is of type 'unknown'
if (typeof safe === "string") console.log(safe.length); // now allowedany switches checking off for everything downstream of it, so one any can silently unprotect a whole call chain. unknown is the honest version: it also holds anything, but you must narrow it before you can use it. Reach for unknown when porting Lua code and let the errors show you what needs a check.Naming a type
The documentation comment on the left is what a Lua codebase uses to record a shape, and nothing verifies it. A type alias says the same thing in a form the compiler reads.
-- Lua has no way to name a shape. The convention is a comment.
-- @param point table with numeric x and y
local function distance_from_origin(point)
return math.sqrt(point.x ^ 2 + point.y ^ 2)
end
print(distance_from_origin({ x = 3, y = 4 }))type Point = { x: number; y: number };
function distanceFromOrigin(point: Point): number {
return Math.sqrt(point.x ** 2 + point.y ** 2);
}
console.log(distanceFromOrigin({ x: 3, y: 4 }));The alias is not a value and does not exist at run time — it is a name for a shape, used only while checking. That is why the compiled output is identical to the equivalent JavaScript, and why the alias can refer to itself for recursive structures.
readonly, which Lua 5.3 cannot express
Enforcing immutability in Lua 5.3 costs a metatable and a run-time error. TypeScript expresses it in the type, so the mistake never reaches run time.
-- No way to mark a field or table unmodifiable in 5.3.
-- The nearest thing is a metatable that refuses writes.
local settings = setmetatable({}, {
__index = { width = 80 },
__newindex = function() error("read-only", 0) end,
})
print(settings.width)
print(pcall(function() settings.width = 100 end))type Settings = { readonly width: number };
const settings: Settings = { width: 80 };
console.log(settings.width);
// settings.width = 100;
// Cannot assign to 'width' because it is a read-only property.
const values: readonly number[] = [1, 2, 3];
// values.push(4); Property 'push' does not exist on 'readonly number[]'Being a compile-time construct,
readonly disappears in the output — the object really is mutable at run time, and code that skipped the checker can write to it. It documents and enforces intent within the checked program, which is a different guarantee from Lua's metatable, and a cheaper one.Optional properties
In Lua every field is optional because a missing key is just
nil. TypeScript makes optionality explicit with ?, and — crucially — makes everything else required.-- Every Lua table field is optional; absence is just nil.
local function describe(options)
local width = options.width or 80
local height = options.height or 24
print(width, height)
end
describe({ width = 100 })type Options = { width?: number; height?: number };
function describe({ width = 80, height = 24 }: Options = {}): void {
console.log(width, height);
}
describe({ width: 100 });
// describe({ widht: 100 }); Object literal may only specify known
// properties, and 'widht' does not existThe commented line is the payoff: a misspelled option key is a compile error rather than a silently ignored field that falls back to the default. That single check catches a large share of real configuration bugs in Lua codebases.
nil Becomes null | undefined
🚨 strictNullChecks is the answer to the nil that travelled
This is the row a Lua programmer comes for. A Lua function that falls off the end returns
nil, the caller usually forgets, and the failure surfaces somewhere else entirely.local function find_user(users, name)
for _, user in ipairs(users) do
if user.name == name then return user end
end
-- falls off the end: returns nil, and nothing says so
end
local user = find_user({}, "ada")
-- print(user.name) -- attempt to index a nil value, three modules latertype User = { name: string };
function findUser(users: User[], name: string): User | undefined {
return users.find((user) => user.name === name);
}
const user = findUser([], "ada");
// console.log(user.name);
// 'user' is possibly 'undefined'. -- caught at the CALL SITE
console.log(user?.name);With
strictNullChecks on, the possibility of absence is part of the return type, and the compiler refuses to let you use the value without handling it. The error appears at the call site, not three modules downstream — which is the whole difference in one line.nil becomes two types
Lua answers "absent", "never assigned" and "deliberately empty" with one
nil. TypeScript inherits JavaScript's split and then makes you write which one you mean.local settings = { width = 80 }
print(settings.height) -- nil: absent
local declared
print(declared) -- nil: never assigned
print(settings.height == declared) -- true: one value for bothtype Settings = { width: number; height?: number };
const settings: Settings = { width: 80 };
console.log(settings.height); // undefined: absent
const cleared: number | null = null; // deliberately empty
console.log(settings.height === cleared); // false: different values
console.log(settings.height == cleared); // true: == treats both as emptyThe convention worth adopting is that
undefined means the language produced it and null means a programmer chose it. A type of number | null | undefined is usually a sign the two got muddled — pick one and stay with it.The and-chain becomes optional chaining
Lua's
and short-circuit is the idiom for reading through a value that might be nil. TypeScript has dedicated syntax, and the type checker knows what each step can produce.local config = { server = nil }
-- Lua's idiom for a safe read through a possibly-nil value:
local port = config.server and config.server.port
print(port)
local with_default = (config.server and config.server.port) or 8080
print(with_default)type Config = { server?: { port: number } };
const config: Config = {};
const port = config.server?.port;
console.log(port);
const withDefault = config.server?.port ?? 8080;
console.log(withDefault);?. stops and yields undefined the moment a link is empty, and ?? supplies a default only for null or undefined — which is exactly Lua's or semantics, restored. Plain || would also replace 0, as the JavaScript page explains.Asserting non-null, and why to avoid it
Lua's
assert is a real run-time check that raises and returns its argument. TypeScript's ! looks similar and is nothing of the sort.-- Lua has no assertion syntax; assert() is a run-time check
-- that raises, and it returns its argument so it can be inlined.
local function get(map, key)
return assert(map[key], "missing key")
end
print(get({ a = 1 }, "a"))const scores = new Map<string, number>([["a", 1]]);
const value = scores.get("a")!; // '!' says "trust me, not undefined"
console.log(value);
const checked = scores.get("a"); // the honest version
if (checked === undefined) throw new Error("missing key");
console.log(checked);The
! suffix removes null and undefined from the static type and generates no check whatsoever — if you are wrong, you get the crash you would have had in Lua, with the type system having promised otherwise. Prefer the explicit test; it costs two lines and is honest.A nil value versus a missing key
Lua cannot store a
nil in a table — assigning one deletes the key, which is why "present but empty" is inexpressible there.local inventory = { sword = 1 }
inventory.sword = nil -- assigning nil REMOVES the key
local count = 0
for _ in pairs(inventory) do count = count + 1 end
print(count)type Inventory = { sword?: number };
const inventory: Inventory = { sword: 1 };
inventory.sword = undefined; // key remains, holding undefined
console.log(Object.keys(inventory).length);
delete inventory.sword; // this removes it
console.log(Object.keys(inventory).length);TypeScript can distinguish the two, and the type system has a flag for it: with
exactOptionalPropertyTypes, a height?: number may be absent but may not be explicitly undefined. That is a distinction Lua has no way to make at all.Structural Types, Not Duck Typing
Shape is the type
TypeScript checks the shape, not the declared name, so any object with the right fields is accepted. That is duck typing made static, and it should feel natural coming from Lua.
-- Lua is duck-typed: if it has the fields, it works,
-- and nothing is checked until the access happens.
local function area(rectangle)
return rectangle.width * rectangle.height
end
print(area({ width = 3, height = 4 }))
print(area({ width = 3, height = 4, color = "red" }))type Rectangle = { width: number; height: number };
function area(rectangle: Rectangle): number {
return rectangle.width * rectangle.height;
}
const box = { width: 3, height: 4, color: "red" };
console.log(area({ width: 3, height: 4 }));
console.log(area(box)); // extra property is fine via a variableThe exception is an object literal passed directly, which gets an excess-property check to catch typos — which is why the extra
color has to arrive through a variable here. Assigning through a variable is the escape hatch, and it is deliberate rather than a hole.interface and type do nearly the same job
An interface names a shape that other things must satisfy. Lua expresses the same idea as a convention nobody can verify — the comment on the left is the entire mechanism.
-- Lua has no interface concept. A "protocol" is a comment
-- plus the hope that every implementer read it.
local function render(drawable)
return drawable:draw()
end
local circle = {}
function circle:draw() return "circle" end
print(render(circle))interface Drawable {
draw(): string;
}
function render(drawable: Drawable): string {
return drawable.draw();
}
const circle: Drawable = { draw: () => "circle" };
console.log(render(circle));Nothing has to declare that it implements
Drawable; having the right members is enough, which keeps the structural spirit. interface and type overlap heavily — the practical differences are that interfaces merge when declared twice and can be extends-ed, while type aliases can express unions.Combining shapes
Composing behavior in Lua means merging tables and hoping the keys do not collide. An intersection type describes the combination without any run-time work at all.
-- Lua merges tables by copying keys, at run time.
local function merge(first, second)
local result = {}
for key, value in pairs(first) do result[key] = value end
for key, value in pairs(second) do result[key] = value end
return result
end
local entity = merge({ x = 1 }, { name = "rock" })
print(entity.x, entity.name)type Positioned = { x: number };
type Named = { name: string };
type Entity = Positioned & Named; // has BOTH
const entity: Entity = { x: 1, name: "rock" };
console.log(entity.x, entity.name);The
& operator produces a type that has every member of both. This is a place where the type system is doing something Lua has no way to state — you can build the merged table either way, but only TypeScript can say what the result is guaranteed to contain.Describing an open-ended table
Not every table has a fixed shape. An index signature describes the open case — arbitrary keys, all mapping to the same type of value — which is what a Lua table does by default.
-- A Lua table is open by nature: any key, any time.
local counts = {}
counts["apples"] = 3
counts["pears"] = 5
for key, value in pairs(counts) do print(key, value) endtype Counts = { [key: string]: number };
const counts: Counts = {};
counts["apples"] = 3;
counts["pears"] = 5;
for (const [key, value] of Object.entries(counts)) {
console.log(key, value);
}Record<string, number> is the more idiomatic spelling of the same thing. Note that reading an arbitrary key still gives you number rather than number | undefined unless noUncheckedIndexedAccess is on — a flag worth turning on precisely because it restores the honesty a Lua programmer expects from a missing key.Two shapes that match are the same type
Structural typing has a cost, and this is it: two types with the same members are interchangeable, so a distance cannot be kept apart from a height by naming alone.
-- Lua cannot distinguish two tables with the same fields.
local meters = { value = 5 }
local feet = { value = 5 }
print(meters.value == feet.value) -- indistinguishabletype Meters = { value: number };
type Feet = { value: number };
const distance: Meters = { value: 5 };
const height: Feet = distance; // allowed: identical shapes
console.log(height.value);
// The workaround, "branding":
type Miles = { value: number; readonly __unit?: "miles" };
// const wrong: Miles = distance; still allowed, but a real brand
// using a unique symbol would reject it.Languages with nominal typing reject that assignment on the name. TypeScript needs a "brand" — an extra unique member — to make two identical shapes distinct. Coming from Lua this is no loss at all, since Lua could never tell them apart either; it is worth knowing only so you do not expect a guarantee you have not asked for.
Union and Literal Types
A value that may be one of several types
The Lua function accepts anything and checks by hand. A union type says exactly which types are allowed, and the compiler then verifies you handled each one.
-- Lua's answer is to check with type() at run time.
local function describe(value)
if type(value) == "number" then
return "number: " .. value
elseif type(value) == "string" then
return "string: " .. value
end
return "something else"
end
print(describe(5), describe("a"))function describe(value: number | string): string {
if (typeof value === "number") {
return "number: " + value;
}
return "string: " + value; // narrowed to string automatically
}
console.log(describe(5), describe("a"));The final
return needs no else: having excluded number, the checker knows the remaining possibility is string. That is narrowing, and it is what makes unions pleasant rather than tedious — the Narrowing section takes it further.Literal types replace a table of allowed strings
Lua checks a string against a set of allowed values at run time, and only if somebody remembered to write the check. A literal type makes the set part of the signature.
-- Lua validates against a set at run time, if at all.
local VALID_MODES = { read = true, write = true }
local function open_file(mode)
if not VALID_MODES[mode] then
error("bad mode: " .. tostring(mode), 0)
end
return "opened for " .. mode
end
print(open_file("read"))
print(pcall(open_file, "excute"))type Mode = "read" | "write";
function openFile(mode: Mode): string {
return "opened for " + mode;
}
console.log(openFile("read"));
// openFile("excute");
// Argument of type '"excute"' is not assignable
// to parameter of type 'Mode'.The typo is caught while typing, the editor offers the two valid values as completions, and the run-time validation disappears entirely. This is the single most immediately useful type-system feature for someone coming from a stringly-typed Lua codebase.
Discriminated unions
A tagged variant is the same idea in both languages — a field naming which case this is. The difference is that TypeScript understands the tag and uses it.
-- The Lua idiom is a 'kind' field checked by hand.
local function area(shape)
if shape.kind == "circle" then
return math.pi * shape.radius ^ 2
elseif shape.kind == "square" then
return shape.side ^ 2
end
error("unknown shape", 0)
end
print(string.format("%.2f", area({ kind = "circle", radius = 1 })))
print(area({ kind = "square", side = 3 }))type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle": return Math.PI * shape.radius ** 2;
case "square": return shape.side ** 2;
}
}
console.log(area({ kind: "circle", radius: 1 }).toFixed(2));
console.log(area({ kind: "square", side: 3 }));Inside
case "circle" the checker knows radius exists and side does not, so a misspelled field is an error rather than a nil. The function also needs no fallback error: having covered both cases, the compiler agrees every path returns, and it would complain if a third variant were added later.Exhaustiveness checking
Adding a case to a Lua union means finding every place that switches on it, by hand and by memory. This is the pattern that makes the compiler do that search.
-- Adding a new kind in Lua silently falls through
-- to the error branch -- at run time, if that path is taken.
local function label(status)
if status == "open" then return "Open" end
if status == "closed" then return "Closed" end
error("unhandled status: " .. tostring(status), 0)
end
print(label("open"))
print(pcall(label, "pending"))type Status = "open" | "closed";
function label(status: Status): string {
switch (status) {
case "open": return "Open";
case "closed": return "Closed";
default: {
const unreachable: never = status; // compile error if a case is added
return unreachable;
}
}
}
console.log(label("open"));Assigning the narrowed value to
never only compiles when every case has already been handled, so adding "pending" to Status turns this line into an error listing exactly what was missed. There is no Lua equivalent, and it is the strongest argument on the page for describing your data.Enums, and why unions are usually better
A Lua enum is a table of constants and nothing stops you passing an unrelated number. TypeScript has a real
enum keyword — and the community mostly recommends against it.-- Lua's enum is a table of constants, unchecked.
local Direction = { UP = 1, DOWN = 2 }
local function move(direction)
return direction == Direction.UP and "up" or "down"
end
print(move(Direction.UP), move(99)) -- 99 is acceptedenum Direction { Up, Down }
function move(direction: Direction): string {
return direction === Direction.Up ? "up" : "down";
}
console.log(move(Direction.Up));
// Usually preferred, because it erases completely:
type Heading = "up" | "down";
const go = (heading: Heading): string => heading;
console.log(go("up"));An
enum is one of the few TypeScript constructs that emits real JavaScript, so it breaks the erasure rule the next section describes. A union of string literals gives the same checking, better error messages, and compiles to nothing.Typing Arrays, Tuples and Records
A homogeneous array
The Lua table on the left is legal and occasionally intentional. TypeScript makes you say whether the mixture is deliberate.
-- A Lua table holds anything, in any mixture.
local values = { 1, "two", true }
print(#values, type(values[2]))const values: number[] = [1, 2, 3];
console.log(values.length, typeof values[1]);
// values.push("four");
// Argument of type 'string' is not assignable to 'number'.
const mixed: (number | string)[] = [1, "two"]; // when you mean it
console.log(mixed.length);The array type is written
number[] or Array<number>; they are identical. Where a Lua table would hold a mixture by accident, the union element type documents that it is on purpose — and forces every read to narrow before using it.Tuples: fixed length, per-position types
Lua returns multiple values and TypeScript returns one array — but a tuple type gives each position its own type and fixes the length, which recovers most of what the multiple-return form gave you.
-- Lua's multiple returns are the closest thing,
-- and they vanish the moment you store them in a table.
local function bounds(numbers)
return math.min(table.unpack(numbers)), math.max(table.unpack(numbers))
end
local low, high = bounds({ 4, 1, 9 })
print(low, high)function bounds(numbers: number[]): [number, number] {
return [Math.min(...numbers), Math.max(...numbers)];
}
const [low, high] = bounds([4, 1, 9]);
console.log(low, high);
const pair: [string, number] = ["age", 36]; // positions have types
console.log(pair[0].toUpperCase(), pair[1].toFixed(1));Because the positions are typed,
pair[0] is a string with string methods and pair[1] is a number. A plain (string | number)[] would force a narrowing check at every access; the tuple avoids it.Record for a keyed table
The hash half of a Lua table becomes an object with an index signature, most idiomatically spelled
Record.local scores = {}
scores["ada"] = 100
scores["grace"] = 95
for name, score in pairs(scores) do print(name, score) endconst scores: Record<string, number> = {};
scores["ada"] = 100;
scores["grace"] = 95;
for (const [name, score] of Object.entries(scores)) {
console.log(name, score);
}
type Fixed = Record<"ada" | "grace", number>; // exactly these keys
const fixed: Fixed = { ada: 100, grace: 95 };
console.log(Object.keys(fixed).length);Keying a
Record on a union of literals is the useful trick: it requires every key to be present and rejects any other, which turns a lookup table into something the compiler checks for completeness. Lua can only do this with a run-time loop over an allowed-keys list.Map preserves key identity
A Lua table keeps
1 and "1" apart. A plain JavaScript object converts every key to a string, so they collide — and TypeScript's object types inherit that.local lookup = {}
lookup[1] = "number key"
lookup["1"] = "string key" -- a DIFFERENT key in Lua
print(lookup[1], lookup["1"])const asObject: Record<string, string> = {};
asObject[1 as unknown as string] = "number key";
asObject["1"] = "string key"; // SAME key: 1 becomes "1"
console.log(Object.keys(asObject).length);
const asMap = new Map<number | string, string>();
asMap.set(1, "number key").set("1", "string key");
console.log(asMap.get(1), asMap.get("1")); // distinct, like LuaMap behaves like a Lua table here: it preserves key identity, accepts objects and functions as keys, and remembers insertion order. Its type parameters also let you say exactly what the keys and values are, which an index signature cannot do for non-string keys.Array methods carry their types through
Lua ships no
map or filter, so the loop on the left is written by hand. The interesting part here is not that TypeScript has them — the JavaScript page covers that — but that the types flow through the chain.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: number[] = [1, 2, 3, 4];
const doubledEvens: number[] = numbers
.filter((value) => value % 2 === 0)
.map((value) => value * 2);
console.log(doubledEvens.join(","));
const names = numbers.map((value) => "n" + value); // string[]
console.log(names[0].toUpperCase());The lambda parameters need no annotation because the element type is known, and
map returns whatever the callback returns, so names is inferred as string[] and gets string methods. That inference through a pipeline is where a type system stops feeling like paperwork.as const freezes a literal into its narrowest type
Without
as const the array on the right would be inferred as string[] and its contents forgotten. With it, the literal keeps its exact shape and values.-- A Lua table of constants is just a table; nothing is fixed.
local DIRECTIONS = { "up", "down" }
DIRECTIONS[1] = "sideways" -- perfectly legal
print(DIRECTIONS[1])const directions = ["up", "down"] as const;
// directions[0] = "sideways";
// Cannot assign to '0' because it is a read-only property.
type Direction = (typeof directions)[number]; // "up" | "down"
const heading: Direction = "up";
console.log(heading, directions.length);The second line is the reason this matters: a union type derived from the array, so the list of valid directions is written once and the type follows. In Lua the equivalent is a table plus a separate hand-maintained validation list that drifts from it.
Typing Functions
Parameter and return types
Parameters take annotations, and the return type follows the parameter list. The return annotation is optional — it is inferred — but writing it is worth the keystrokes.
local function add(left, right)
return left + right
end
print(add(2, 3))function add(left: number, right: number): number {
return left + right;
}
console.log(add(2, 3));
const multiply = (left: number, right: number): number => left * right;
console.log(multiply(2, 3));An explicit return type makes the compiler check the body against your intent rather than inferring whatever the body happens to produce. Without it, a bug that changes what a function returns silently changes its type and the error appears at the call sites instead.
Typing a function you accept as an argument
Lua accepts any callable and finds out at run time whether it works. A function type states the parameters and the result, so a mismatched callback is caught at the call site.
local function apply(numbers, transform)
local results = {}
for index, value in ipairs(numbers) do
results[index] = transform(value)
end
return results
end
print(table.concat(apply({ 1, 2 }, function(value) return value * 2 end), ","))function apply(
numbers: number[],
transform: (value: number) => number,
): number[] {
return numbers.map(transform);
}
console.log(apply([1, 2], (value) => value * 2).join(","));The arrow in a type position —
(value: number) => number — is a signature, not a function. It reads the same as an arrow function and means something different, which is worth a second look the first few times.Optional and default parameters
Lua fakes defaults with the
or idiom. TypeScript has real defaults, and separately a ? marker for a parameter that may simply be absent.local function greet(name, greeting)
greeting = greeting or "Hello"
return greeting .. ", " .. name
end
print(greet("Ada"))
print(greet("Ada", "Welcome"))function greet(name: string, greeting: string = "Hello"): string {
return greeting + ", " + name;
}
console.log(greet("Ada"));
console.log(greet("Ada", "Welcome"));
function find(name: string, limit?: number): string {
return name + ":" + (limit ?? 10); // 'limit' is number | undefined
}
console.log(find("a"), find("a", 3));The difference between the two is worth keeping straight: a default makes the parameter's type non-optional inside the body, while
? makes it number | undefined and forces you to handle the absence. Lua also silently accepts too many arguments, where TypeScript rejects them.Typed varargs
Lua's
... must be packed into a table before iterating and carries no type information at all. A rest parameter arrives as a typed array.local function sum(...)
local total = 0
for _, value in ipairs({ ... }) do total = total + value end
return total
end
print(sum(1, 2, 3))function sum(...values: number[]): number {
return values.reduce((running, value) => running + value, 0);
}
console.log(sum(1, 2, 3));
// sum(1, "2");
// Argument of type 'string' is not assignable to 'number'.Every argument is checked against the element type, so a stray string is caught at the call. Lua would accept it, add it to the table, and fail on the arithmetic at run time — or worse, coerce it silently if it happened to be numeric.
Overloads describe several shapes of one function
Lua functions routinely change behavior based on how many arguments arrived, and the caller has no way to know which form they got. Overload signatures describe each form separately.
-- Lua branches on argument count or type by hand.
local function make(first, second)
if second == nil then
return { size = first }
end
return { width = first, height = second }
end
print(make(5).size)
print(make(3, 4).width)function make(size: number): { size: number };
function make(width: number, height: number): { width: number; height: number };
function make(first: number, second?: number) {
return second === undefined ? { size: first } : { width: first, height: second };
}
console.log(make(5).size);
console.log(make(3, 4).width);The implementation signature is not callable from outside — only the overloads are — so
make(5).width is an error while make(5).size is fine. This is a case where the type layer is describing something Lua does all the time and could never express.void and never
Lua does not distinguish "returns nothing" from "never returns" — both just end. TypeScript has a type for each.
local function log_it(message)
print(message)
-- returns no values
end
local function fail(message)
error(message, 0) -- never returns normally
end
log_it("done")
print(pcall(fail, "boom"))function logIt(message: string): void {
console.log(message);
}
function fail(message: string): never {
throw new Error(message);
}
logIt("done");
try { fail("boom"); } catch (error) { console.log(false, (error as Error).message); }void means the return value is not meant to be used; never means control does not reach the end at all, which lets the checker treat any code after such a call as unreachable. never is also what makes the exhaustiveness check in the Unions section work.Classes and Interfaces
class, with typed fields
The metatable pattern on the left is what every Lua codebase writes by hand. A class declares the same thing, with the field types recorded.
local Counter = {}
Counter.__index = Counter
function Counter.new()
return setmetatable({ count = 0 }, Counter)
end
function Counter:increment()
self.count = self.count + 1
end
local counter = Counter.new()
counter:increment()
print(counter.count)class Counter {
count: number = 0;
increment(): void {
this.count += 1;
}
}
const counter = new Counter();
counter.increment();
console.log(counter.count);Remember from
/lua/javascript that this is bound by the call site rather than being a parameter the colon fills in — that difference is unchanged here, and TypeScript does not fix it. What it adds is that counter.cout is now a compile error instead of nil.private, which Lua fakes with a closure
Lua's only real privacy is a closure, at the cost of one closure per instance. TypeScript offers two mechanisms, and they differ in an important way.
-- Real privacy in Lua means a closure over a local.
local function make_account(balance)
local account = {}
function account.deposit(amount) balance = balance + amount end
function account.balance_of() return balance end
return account
end
local account = make_account(100)
account.deposit(50)
print(account.balance_of())class Account {
private balance: number;
readonly #secret = "truly private"; // enforced at run time
constructor(balance: number) { this.balance = balance; }
deposit(amount: number): void { this.balance += amount; }
balanceOf(): number { return this.balance; }
}
const account = new Account(100);
account.deposit(50);
console.log(account.balanceOf());
// account.balance; Property 'balance' is privateprivate is checked by the compiler and erased — the field is a normal property at run time and reachable from JavaScript. A # field is genuinely private in the runtime, which is the closer match to the Lua closure. Prefer # when the guarantee matters rather than the documentation.implements checks a class against an interface
Structural typing means a class satisfying the shape is accepted whether or not it says so. The
implements clause adds a check at the declaration.-- Nothing verifies that a Lua table satisfies a "protocol".
local Circle = {}
Circle.__index = Circle
function Circle.new(radius) return setmetatable({ radius = radius }, Circle) end
function Circle:draw() return "circle" end
local function render(drawable) return drawable:draw() end
print(render(Circle.new(1)))interface Drawable {
draw(): string;
}
class Circle implements Drawable {
constructor(private radius: number) {}
draw(): string { return "circle of " + this.radius; }
// Omitting draw() would be an error at the CLASS, not at the call site.
}
function render(drawable: Drawable): string { return drawable.draw(); }
console.log(render(new Circle(1)));It changes nothing about assignability — the class would still be accepted without it — but it moves the error to the class definition, where it is far easier to fix than at some distant call site. Note also the constructor parameter property:
private radius declares and assigns the field in one place.abstract classes
The Lua idiom is a base method that raises if the subclass forgot to override it — a run-time reminder, discovered only when that path executes.
-- Lua's version is a base method that raises.
local Shape = {}
Shape.__index = Shape
function Shape:area()
error("subclass must implement area", 0)
end
local Square = setmetatable({}, { __index = Shape })
Square.__index = Square
function Square.new(side) return setmetatable({ side = side }, Square) end
function Square:area() return self.side ^ 2 end
print(Square.new(3):area())
print(pcall(function() return setmetatable({}, Shape):area() end))abstract class Shape {
abstract area(): number;
describe(): string { return "area is " + this.area(); }
}
class Square extends Shape {
constructor(private side: number) { super(); }
area(): number { return this.side ** 2; }
}
console.log(new Square(3).describe());
// new Shape(); Cannot create an instance of an abstract class.An
abstract member moves that to compile time twice over: a subclass that does not implement it fails to compile, and the base class cannot be instantiated at all. The base can still provide concrete methods that call the abstract one, exactly as describe does.Getters, where Lua reaches for __index
A computed property in Lua means an
__index function that inspects the key and falls back for everything else — the awkwardness on the left is entirely representative.local Temperature = {}
Temperature.__index = function(self, key)
if key == "fahrenheit" then
return rawget(self, "celsius") * 9 / 5 + 32
end
return rawget(Temperature, key)
end
local temperature = setmetatable({ celsius = 100 }, Temperature)
print(temperature.fahrenheit)class Temperature {
constructor(public celsius: number) {}
get fahrenheit(): number {
return this.celsius * 9 / 5 + 32;
}
}
const temperature = new Temperature(100);
console.log(temperature.fahrenheit);A getter is a property whose value comes from a method, and it is typed like any other member, so
temperature.fahrenheit is a number to the checker. The public celsius constructor parameter again declares and assigns in one place.Generics, Which Lua Has No Notion Of
A function that preserves its argument type
The Lua function is already generic in the everyday sense — it works on any list. What it cannot do is tell the caller that a list of strings gives back a string.
-- Lua works on anything, and knows nothing about what it returned.
local function first(list)
return list[1]
end
print(first({ 1, 2 }), first({ "a", "b" }))function first<Element>(list: Element[]): Element | undefined {
return list[0];
}
const number = first([1, 2]); // number | undefined
const text = first(["a", "b"]); // string | undefined
console.log(number, text?.toUpperCase());The type parameter
Element carries that relationship through. Note the return is Element | undefined because the list may be empty, which is strictNullChecks making explicit the exact case a Lua caller forgets.Constraining a type parameter
The Lua version applies
# to whatever arrives and fails at run time on a number. A constraint states the requirement in the signature.-- Lua checks at run time, if at all.
local function longest(first, second)
if #first >= #second then return first end
return second
end
print(longest("hello", "hi"))
print(#longest({ 1, 2, 3 }, { 1 }))function longest<Item extends { length: number }>(first: Item, second: Item): Item {
return first.length >= second.length ? first : second;
}
console.log(longest("hello", "hi"));
console.log(longest([1, 2, 3], [1]).length);
// longest(5, 6);
// Argument of type 'number' is not assignable to
// parameter of type '{ length: number; }'.extends { length: number } means "any type that has a numeric length", which is structural typing applied to a type parameter — so strings, arrays and your own types all qualify without declaring anything. The return type is Item, so passing strings gives back a string.Generic containers
The Lua stack accepts anything, which is convenient until something unexpected lands in it and surfaces far from where it was pushed.
-- A Lua "stack" is a table plus a convention.
local Stack = {}
Stack.__index = Stack
function Stack.new() return setmetatable({ items = {} }, Stack) end
function Stack:push(item) self.items[#self.items + 1] = item end
function Stack:pop()
local item = self.items[#self.items]
self.items[#self.items] = nil
return item
end
local stack = Stack.new()
stack:push(1)
stack:push("mixed in by accident")
print(stack:pop())class Stack<Item> {
private items: Item[] = [];
push(item: Item): void { this.items.push(item); }
pop(): Item | undefined { return this.items.pop(); }
}
const stack = new Stack<number>();
stack.push(1);
// stack.push("mixed in by accident"); Argument of type 'string'...
console.log(stack.pop()?.toFixed(1));A generic class fixes the element type at construction, so the accidental push is caught and
pop() gives back a number | undefined with number methods. This is the shape most standard-library containers take — Array<T>, Map<K, V>, Set<T>.Types computed from other types
When a Lua codebase has a full record and a partial update, both shapes are written out and kept in step by hand — and they drift.
-- Lua cannot derive one shape from another; you write both
-- and keep them in step by hand.
local User = { id = 1, name = "Ada", email = "ada@example.com" }
local UserUpdate = { name = "Ada Lovelace" } -- a partial, by convention
print(User.name, UserUpdate.name)type User = { id: number; name: string; email: string };
type UserUpdate = Partial<User>; // every field optional
type PublicUser = Omit<User, "email">; // minus one field
type UserName = Pick<User, "name">; // just one field
const update: UserUpdate = { name: "Ada Lovelace" };
const shown: PublicUser = { id: 1, name: "Ada" };
console.log(update.name, shown.name);Partial, Omit, Pick, Required and Readonly derive one type from another, so adding a field to User updates all of them. This is the type system computing rather than merely describing, and it has no counterpart in any dynamically typed language.Type arguments are usually inferred
Generics look heavier than they are in practice, because the type arguments are almost always inferred from the values you pass.
local function pair(first, second)
return { first = first, second = second }
end
local result = pair("a", 1)
print(result.first, result.second)function pair<First, Second>(first: First, second: Second) {
return { first, second };
}
const result = pair("a", 1); // no <string, number> needed
console.log(result.first.toUpperCase(), result.second.toFixed(1));The call reads exactly like the Lua one, and yet
result.first is a string with string methods and result.second is a number. Explicit type arguments — pair<string, number>("a", 1) — are only needed when inference has nothing to work from.Narrowing, and Types at Run Time
typeof narrows, where Lua just tests
Both languages test the type at run time with a similar-looking check. The difference is that TypeScript's compiler follows the test and updates what it knows.
local function length_of(value)
if type(value) == "string" then
return #value
elseif type(value) == "table" then
return #value
end
return 0
end
print(length_of("hello"), length_of({ 1, 2 }), length_of(5))function lengthOf(value: string | number[]): number {
if (typeof value === "string") {
return value.length; // 'value' is string here
}
return value.length; // and number[] here, with no else
}
console.log(lengthOf("hello"), lengthOf([1, 2]));Inside the
if, value is a string and string methods are available; after it, the only remaining possibility is number[]. The Lua version has the same control flow and no such knowledge — #value would be equally accepted on a number and fail at run time.A nil check narrows the type
The guard clause is written the same way in both languages. Only one of them then knows that the value cannot be empty below the check.
local function shout(name)
if name == nil then
return "NOBODY"
end
return name:upper()
end
print(shout(nil), shout("ada"))function shout(name: string | undefined): string {
if (name === undefined) {
return "NOBODY";
}
return name.toUpperCase(); // narrowed to string
}
console.log(shout(undefined), shout("ada"));This is why
strictNullChecks is bearable rather than exhausting: you write the check you should have written anyway, once, and everything after it is unencumbered. Remember the truthiness caveat from /lua/javascript — if (!name) would also catch the empty string, which is usually not what you meant.Narrowing on a property
Testing for a field to work out which variant you have is a familiar Lua idiom. The
in operator does the same job and narrows the union while it is at it.local function describe(shape)
if shape.radius then
return "circle"
elseif shape.side then
return "square"
end
return "unknown"
end
print(describe({ radius = 1 }), describe({ side = 2 }))type Circle = { radius: number };
type Square = { side: number };
function describe(shape: Circle | Square): string {
if ("radius" in shape) {
return "circle of " + shape.radius;
}
return "square of " + shape.side;
}
console.log(describe({ radius: 1 }), describe({ side: 2 }));A subtlety the Lua version hides: testing
shape.radius for truthiness fails when the radius is legitimately 0. The in test asks whether the property exists, which is the question you actually meant — and it is the reason the discriminated union from the Unions section is better still.Teaching the checker your own test
Both write a function that checks a shape. The
value is Point return type is the part with no Lua counterpart: it tells the compiler what a true result means.-- A Lua predicate is an ordinary function returning a boolean;
-- nothing downstream benefits from having called it.
local function is_point(value)
return type(value) == "table"
and type(value.x) == "number"
and type(value.y) == "number"
end
local candidate = { x = 1, y = 2 }
if is_point(candidate) then
print(candidate.x + candidate.y)
endtype Point = { x: number; y: number };
function isPoint(value: unknown): value is Point {
return typeof value === "object" && value !== null
&& typeof (value as Point).x === "number"
&& typeof (value as Point).y === "number";
}
const candidate: unknown = { x: 1, y: 2 };
if (isPoint(candidate)) {
console.log(candidate.x + candidate.y); // narrowed to Point
}Inside the
if, candidate is a Point and its fields are typed. This is the sanctioned way to bring untrusted data — parsed JSON, a value from a Lua interop boundary — into the type system, and it is the right tool where a bare cast would be a lie.The checker cannot see your data
This is the most important row in the section, and the one that most often bites someone new to a type system. A cast is an assertion, not a check.
-- Lua treats decoded data like any other table:
-- there was never a promise to break.
local decoded = { name = "Ada" } -- imagine this came from JSON
print(decoded.name)
print(decoded.age) -- nil, and life goes ontype User = { name: string; age: number };
const decoded = JSON.parse('{"name":"Ada"}') as User; // a LIE
console.log(decoded.name);
console.log(decoded.age); // typed as number, actually undefined
console.log(typeof decoded.age); // "undefined" at run timeas User tells the compiler to stop asking questions; it verifies nothing and generates no code. decoded.age is a number according to the checker and undefined in reality, so the crash lands somewhere else — exactly the Lua failure mode this page opened by promising to remove. Validate at the boundary with a type predicate or a schema library.The Types Are Erased
🚨 Nothing about the types survives compilation
Everything this page has described — aliases, interfaces, generics, unions,
readonly, private — is removed before the program runs. The output is plain JavaScript.-- Lua's type() is a run-time question with a run-time answer.
local function describe(value)
return type(value)
end
print(describe(1), describe("a"), describe({}))type Point = { x: number; y: number };
const point: Point = { x: 1, y: 2 };
// There is no way to ask "is this a Point?" at run time --
// the type does not exist in the output. Only JavaScript's own
// typeof survives, and it knows nothing about your aliases.
console.log(typeof point); // "object"
console.log(Object.keys(point).join(","));So there is no reflection over your types, no
instanceof for an interface, and no way to generate a validator automatically. Lua's type() is weaker but it is there at run time, which is the trade: TypeScript checks more, and knows nothing once it is running.A generic parameter cannot be inspected
A type parameter is not a value, and by the time the function runs it has been erased. There is nothing to switch on.
-- Lua would branch on the value it actually received.
local function make_default(kind)
if kind == "number" then return 0 end
if kind == "string" then return "" end
return nil
end
print(make_default("number"), make_default("string"))function makeDefault<Item>(): Item | undefined {
// There is no 'Item' at run time to switch on.
return undefined;
}
// The workaround is to pass a value that carries the information:
function makeDefaultFrom<Item>(example: Item): Item { return example; }
console.log(makeDefaultFrom(0), makeDefaultFrom(""));The remedy is always to pass something concrete — a sample value, a constructor, a discriminant string — so the information exists at run time. A Lua programmer will find this natural, since passing the kind explicitly is what the left column already does.
The few constructs that do emit code
The erasure rule has exceptions, and knowing them is what makes the compiled output predictable.
-- Everything in Lua is code; there is no declaration-only construct.
local Direction = { UP = 1, DOWN = 2 }
print(Direction.UP)enum Direction { Up = 1, Down = 2 } // emits a real object
console.log(Direction.Up, Direction[1]);
class Marker { constructor(public label: string) {} } // emits a class
console.log(new Marker("here").label);
// type / interface / generic parameters / 'as' / '!' emit NOTHING.Classes emit classes and enums emit a lookup object — with reverse mapping for numeric ones, which is why
Direction[1] works. Everything else in the type layer vanishes. This is the main argument for preferring a union of string literals over an enum: it costs nothing at run time.Compiling and checking are separate
A point that catches people out, and one this page's own test suite has to work around: type checking and producing JavaScript are two separate jobs, and most fast toolchains do only the second.
-- Lua compiles a chunk and refuses only on syntax errors.
local ok, message = pcall(load, "local x = ")
print(ok, message ~= nil)// A type error does NOT necessarily stop the output being produced.
// tsc reports the error and still emits JavaScript unless
// 'noEmitOnError' is set; tools like tsx and esbuild strip types
// WITHOUT CHECKING AT ALL, so a type error runs perfectly happily.
const value: number = 1;
console.log(value);That is why the deliberately-wrong lines on this page are written as comments rather than live code — the suite runs examples through
tsx, which transpiles without checking, so a real type error would pass the tests and fail in the browser's compiler. Run tsc --noEmit to actually check.Modules, Config, and TypeScriptToLua
require becomes import
Both load a module once and cache it. What is different is that a TypeScript import carries the type information, so the checker verifies the call across a file boundary.
-- A Lua module returns a value; you name it yourself.
local geometry = {}
function geometry.area(width, height) return width * height end
-- return geometry
print(geometry.area(3, 4))// A TypeScript module exports named bindings, and the types
// travel with them across the import.
// export function area(width: number, height: number): number
// import { area } from "./geometry.js";
function area(width: number, height: number): number {
return width * height;
}
console.log(area(3, 4));The export lines are commented because this page's runner evaluates each example as a standalone script. Note the
.js extension in the import even though the source is .ts — the specifier names the emitted file, which surprises everyone once.The strictness is a setting
There is no single TypeScript language. How much it checks is a project setting, and a codebase with
strict off is a very different thing from one with it on.-- Lua has no configuration that changes the language.
-- What you write is what runs, everywhere.
print(_VERSION)// tsconfig.json decides how much checking you get:
// "strict": true the umbrella; turn this on
// "strictNullChecks": true null and undefined are tracked
// "noUncheckedIndexedAccess": true arr[i] is T | undefined
// "exactOptionalPropertyTypes": true '?' means absent, not undefined
// "noImplicitAny": true an un-inferrable parameter errors
console.log("configuration decides the dialect");For a Lua programmer adopting TypeScript the advice is simple: turn
strict on from the first day. Retrofitting it later means fixing every unhandled empty value at once, which is the same debt Lua leaves you with and much harder to pay all at once.TypeScriptToLua compiles back to your runtime
This is the concrete reason many Lua programmers meet TypeScript at all. TypeScriptToLua keeps the runtime you already have — Roblox, Defold, a game engine embedding Lua — and changes only what you write.
-- The Lua your engine actually runs.
local function greet(name)
return "Hello, " .. name
end
print(greet("Ada"))// With TypeScriptToLua (tstl), this file compiles to Lua rather
// than JavaScript -- so the checked source is TypeScript and the
// artifact your engine loads is still Lua.
function greet(name: string): string {
return "Hello, " + name;
}
console.log(greet("Ada"));The emitted Lua is readable and 1-based indexing is handled at the boundary, but the mapping is not total: JavaScript-specific library calls have no Lua counterpart, and you write against declaration files describing your host's API rather than the DOM. It is the strongest argument for reading this page rather than only
/lua/javascript.Describing an untyped library
Every Lua host — an engine, a plugin API, a C module — presents functions that are documented only in prose. A declaration file states that API in a form the checker reads.
-- A Lua library is documented in prose and read by humans.
-- Nothing checks that you called it correctly.
local function engine_spawn(name, x, y)
return { name = name, x = x, y = y }
end
print(engine_spawn("rock", 1, 2).name)// A .d.ts file describes an existing runtime's API with no
// implementation, so calls into it are checked:
// declare function engineSpawn(
// name: string, x: number, y: number
// ): { name: string; x: number; y: number };
declare const engineVersion: string | undefined;
console.log(typeof engineVersion);The file contains types and no code, so it emits nothing and can describe something implemented in another language entirely. For a TypeScriptToLua project this is how your engine's API becomes checked, and writing it is usually the first real task in adopting the toolchain.
Gotchas for Lua Developers
as is a promise, not a check
A Lua programmer meeting
as naturally reads it as a conversion. It is not — it changes what the compiler believes and generates no code at all.-- Lua has no casts because it has no static claims to make.
local value = "not a number"
print(type(value))const value = "not a number" as unknown as number;
console.log(typeof value); // "string" at run time
// console.log(value.toFixed(2)); // compiles, then CRASHESThe double cast through
unknown is how you override a rejection the compiler was right about, and it should be rare enough to justify a comment each time. When you genuinely need a conversion, call Number(value); when you need a check, write a type predicate.Indexing an array lies by default
Lua returns
nil for an out-of-range index and every Lua programmer expects it. TypeScript types the read as number and is simply wrong.local values = { 1, 2, 3 }
print(values[99]) -- nil, and everyone knows it might beconst values: number[] = [1, 2, 3];
const missing = values[99]; // typed 'number' -- but it is undefined
console.log(missing);
// missing.toFixed(2); compiles, crashes at run time
// With "noUncheckedIndexedAccess": true it becomes number | undefined
// and the compiler makes you handle it.
console.log(values.at(99)); // typed number | undefined alreadyThis is the one place where the default settings are less honest than Lua. Turn on
noUncheckedIndexedAccess, or use .at(), which is typed as possibly-undefined already. Without it, the type system quietly promises something it cannot deliver.Object keys are strings; Lua keys are not
A Lua table keeps
1 and "1" apart. A JavaScript object does not, and Record<number, string> does not save you — the keys are still strings at run time.local lookup = {}
lookup[1] = "number key"
lookup["1"] = "string key"
local count = 0
for _ in pairs(lookup) do count = count + 1 end
print(count) -- 2: distinct keysconst lookup: Record<string, string> = {};
lookup["1"] = "string key";
console.log(Object.keys(lookup).length); // 1
// Numeric keys are converted, and the TYPE does not warn you:
const byNumber: Record<number, string> = { 1: "a" };
console.log(Object.keys(byNumber)[0], typeof Object.keys(byNumber)[0]);The type says
number and Object.keys hands back "1". Use a Map when the keys are genuinely numeric and their identity matters, which is what the Collections section recommends for the same reason.An unrelated type can satisfy yours
Structural typing means a
Window is a perfectly good Rectangle, because it has the members. Coming from a nominal language this is alarming; coming from Lua it is exactly what you already expected.-- Lua has no types to confuse, so nothing to warn about.
local function draw(shape) return shape.width * shape.height end
print(draw({ width = 2, height = 3 }))type Rectangle = { width: number; height: number };
type Window = { width: number; height: number; title: string };
function area(rectangle: Rectangle): number {
return rectangle.width * rectangle.height;
}
const window: Window = { width: 2, height: 3, title: "Main" };
console.log(area(window)); // accepted: Window has the required shapeIt is listed as a gotcha only because the type annotation creates an expectation of nominal checking that TypeScript does not provide. If two shapes must not be interchangeable, they need a brand — see the Structural section.
One any unprotects everything downstream
A single
any does not stay put: everything read from it is also any, so the typo below it goes unreported exactly as it would in Lua.-- Every Lua value is unchecked, so there is nothing to lose.
local data = { user = { name = "Ada" } }
print(data.user.name)
print(data.user.nmae) -- nil, silentlyconst data: any = { user: { name: "Ada" } };
console.log(data.user.name);
console.log(data.user.nmae); // undefined -- no error reported
const checked: unknown = { user: { name: "Ada" } };
// console.log(checked.user); 'checked' is of type 'unknown'
console.log(typeof checked);The danger is that
any usually enters at a boundary — a parsed JSON payload, an untyped library — and then spreads through the code that touches it, quietly removing the guarantees the rest of the file appears to have. Start from unknown and narrow, and the compiler will show you every place a check is missing.