Objects, Arrays & Tuples
Object types and their modifiers
Section titled “Object types and their modifiers”An object type describes a shape by listing properties. Two modifiers carry a lot of meaning:
interface User { readonly id: string; // can't be reassigned after creation name: string; // required email?: string; // optional — string | undefined}readonlyblocks reassignment of a property. It’s compile-time only (nothing stops mutation at runtime), but it documents and enforces intent.?(optional) means the property may be absent. Crucially, an optional property has typeT | undefined, so you must handle theundefinedcase:
// @expect-error TS18048interface User { email?: string;}
function greet(u: User) { u.email.toLowerCase(); // ❌ 'u.email' is possibly 'undefined'. u.email?.toLowerCase(); // ✅ optional chaining handles the absence}interface and type are nearly interchangeable for object shapes; interface can be re-opened (declaration merging) and is idiomatic for public object contracts, while type can also express unions, tuples, and mapped types. Use type when you need those; either is fine for a plain object.
Excess property checks: only on a fresh literal
Section titled “Excess property checks: only on a fresh literal”Assigning an object literal directly where a type is expected gets an extra check that ordinary assignments don’t: every property in the literal must be known to the target type.
// @expect-error TS2561interface Options { width: number; height?: number }function create(opts: Options) {}
create({ width: 100, heigth: 200 });// ❌ Object literal may only specify known properties, but 'heigth' does not exist// in type 'Options'. Did you mean to write 'height'?The check exists only to catch typos on values the compiler has never seen assigned anywhere else — it does not apply once the object passes through a variable first, because at that point the object could legitimately have more properties than the target type requires (structural typing is about at least having the required shape):
interface Options { width: number; height?: number }function create(opts: Options) {}
const o = { width: 100, heigth: 200 }; // same typocreate(o); // ✅ no error -- o is not a fresh literal anymoreThis is a real gap, not a bug: o might be shared with other code that legitimately reads heigth for some other reason. If you want the strictness back, annotate the variable itself (const o: Options = { ... }), which re-triggers the fresh-literal check at the point of declaration.
exactOptionalPropertyTypes: ? vs. | undefined
Section titled “exactOptionalPropertyTypes: ? vs. | undefined”By default, an optional property width?: number accepts an explicit undefined assignment, the same as an omitted property. exactOptionalPropertyTypes (off by default, even under strict) makes the distinction real: ? means “may be absent,” not “may be undefined.”
interface Options { width?: number }const a: Options = { width: undefined }; // compiles todayRun with the flag on (npx tsc --noEmit --strict --exactOptionalPropertyTypes …) and the same file fails:
error TS2375: Type '{ width: undefined; }' is not assignable to type 'Options' with'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of thetarget's properties. Types of property 'width' are incompatible. Type 'undefined' is not assignable to type 'number'.Turn it on when your code distinguishes “the caller didn’t pass this” from “the caller explicitly passed undefined” (common with partial updates and form state) — without it, both collapse to the same runtime value and the type system can’t tell you apart.
object vs Object vs {}: three different permissions
Section titled “object vs Object vs {}: three different permissions”These three look almost identical and behave very differently. Under strict, all three reject null and undefined — but they disagree sharply about primitives:
// @expect-error TS2322let a: object;let b: Object;let c: {};
a = { x: 1 }; b = { x: 1 }; c = { x: 1 }; // ✅ all three
a = 5; // ❌ object explicitly excludes primitiveslet b: Object;let c: {};b = 5; // ✅ Object accepts primitives -- they structurally have toString/valueOf/etc.c = 5; // ✅ {} means "any value except null/undefined" -- almost as permissive as Objectobject(lowercase, introduced specifically for this) means “anything that isn’t a primitive” — the true non-primitive type.Objectis the interface withtoString,valueOf,hasOwnProperty, and friends — primitives structurally have those methods too (via boxing), so they’re assignable.{}means “a value with no required properties” — which almost every non-nullish value satisfies, primitives included.
In practice: reach for object when you specifically mean “not a primitive,” reach for a real interface or unknown for everything else, and avoid Object/{} as parameter types — they accept far more than they look like they should.
ReadonlyArray, array holes, and noUncheckedIndexedAccess
Section titled “ReadonlyArray, array holes, and noUncheckedIndexedAccess”readonly number[] (or ReadonlyArray<number>) forbids mutating methods like push and index assignment:
// @expect-error TS2339function paint(colors: readonly string[]) { colors.push("red"); // ❌ push does not exist on readonly string[]}Preferring readonly array parameters is a cheap, high-value habit — it prevents a whole class of accidental-mutation bugs. But readonly says nothing about a different hazard: reading past the end. By default, an out-of-bounds index is typed as if it always succeeds:
const arr: number[] = [1, 2, 3];const x = arr[10]; // out of bounds -- but typed as number, not number | undefinedconsole.log(x.toFixed(2)); // compiles -- crashes at runtimeTurning on noUncheckedIndexedAccess (not part of strict, on by name only) makes every index access honest by adding | undefined:
error TS18048: 'x' is possibly 'undefined'.This is the single highest-value flag not in strict — index signatures (from earlier) and plain array access both get the same honesty about absence, at the cost of an extra narrowing check wherever you index by a variable.
Optional and variadic tuple elements
Section titled “Optional and variadic tuple elements”Tuples support an optional element (?, must come after required elements) and a variadic rest element in any position, not just the end:
type Point = [x: number, y: number, z?: number];const p1: Point = [1, 2]; // ✅ z omittedconst p2: Point = [1, 2, 3]; // ✅ z provided
type Padded = [start: number, ...middle: string[], end: boolean]; // rest in the middleconst pd: Padded = [1, "a", "b", true];
function measure(...args: [string, ...number[]]): void {}measure("cm", 1, 2, 3); // the rest parameter itself is typed as a variadic tupleAn optional tuple element’s type includes undefined just like an optional object property; a variadic rest element can sit anywhere in the tuple as long as the compiler can still tell where it ends (usually because a fixed suffix follows it).
Index signatures: objects as maps
Section titled “Index signatures: objects as maps”When keys aren’t known ahead of time, an index signature types them uniformly:
interface Scores { [player: string]: number;}
const s: Scores = { ada: 10, alan: 12 };s.grace = 9; // ✅ any string key, number valueThe cost is honesty about absence: s.someone is typed number but is actually undefined at runtime. With the noUncheckedIndexedAccess compiler option, TypeScript makes this real by typing every index access as number | undefined — a strongly recommended setting.
Arrays vs tuples
Section titled “Arrays vs tuples”An array is a homogeneous, variable-length list: number[] is “any number of numbers.” A tuple is a fixed-length list where each position has its own type:
let list: number[] = [1, 2, 3, 4]; // any length, all numberlet point: [number, number] = [10, 20]; // exactly two numberslet pair: [string, number] = ["age", 36]; // position 0 string, position 1 numberTuples power patterns like React’s useState return ([value, setter]) and named-ish coordinates. They also support labels (documentation only, no runtime effect) and rest elements:
type HttpResult = [status: number, body: string];type Args = [first: string, ...rest: number[]];flowchart TB arr["number[] any length · all same type"] --> arrEx["[1, 2, 3, 4, ...]"] tup["[string, number] fixed length · per-position type"] --> tupEx["['age', 36]"]
readonly arrays and tuples
Section titled “readonly arrays and tuples”readonly number[] (or ReadonlyArray<number>) forbids mutating methods like push and index assignment — great for parameters you promise not to change. And recall from the last lesson: as const on an array literal produces exactly a readonly tuple of literals:
const rgb = [255, 128, 0] as const;// type: readonly [255, 128, 0]
function paint(color: readonly number[]) { /* can't mutate color */ }Preferring readonly array parameters is a cheap, high-value habit: it prevents a whole class of accidental-mutation bugs and communicates ownership.
An enum declares a set of named constants. Unlike almost everything else in this course, it is not erased — it generates real runtime code, which is why it needs its own section and why it collides with every “erasable syntax” tool later in the course.
Numeric enums have a reverse mapping
Section titled “Numeric enums have a reverse mapping”A plain enum numbers its members from 0 unless you say otherwise, and — uniquely among TypeScript features — the compiler also emits a reverse mapping from value back to name:
enum Color { Red, Green, Blue }console.log(Color.Green); // 1 -- forward: name to valueconsole.log(Color[1]); // "Green" -- reverse: value to nameEnums are non-erasable, so — unlike every other fence in this course — this one can’t be executed with node --experimental-strip-types (see below); it only type-checks here. Compiling it for real (tsc --module commonjs) and running the emitted JavaScript with plain node prints 1 then Green, matching the comments above. That reverse mapping is real generated JavaScript, not a type-level trick:
var Color;(function (Color) { Color[Color["Red"] = 0] = "Red"; Color[Color["Green"] = 1] = "Green"; Color[Color["Blue"] = 2] = "Blue";})(Color || (Color = {}));Each line does two assignments: Color["Red"] = 0 (forward) and Color[0] = "Red" (reverse) — that’s the whole mechanism.
String enums have no reverse mapping
Section titled “String enums have no reverse mapping”A string enum only assigns forward, so indexing by value doesn’t work — and the emitted object proves it:
// @expect-error TS2551enum Direction { Up = "UP", Down = "DOWN" }console.log(Direction.Up);console.log(Direction["UP"]); // ❌ typo -- the member is named "Up", not "UP"Compiling the corrected version and running the emitted JavaScript (tsc --module commonjs, then plain node, since string enums are non-erasable and can’t run under --experimental-strip-types either — see below) shows the runtime object directly:
console.log(Direction) → { Up: 'UP', Down: 'DOWN' }Only two forward keys — no 0/1 reverse entries, because there’s nothing numeric to map back from.
A numeric enum’s “type safety” only catches literals, not real numbers
Section titled “A numeric enum’s “type safety” only catches literals, not real numbers”A numeric enum rejects an out-of-range literal, which feels like real type safety:
// @expect-error TS2345enum Color { Red, Green, Blue }function paint(c: Color) {}paint(999); // out of range -- no member has this valueBut that check only fires for literals the compiler can see and match against a member’s value — an arbitrary number at runtime (an API response, parseInt output, anything not a literal) sails through with zero checking, range included:
enum Color { Red, Green, Blue }function paint(c: Color) {}
paint(Color.Green); // ✅ intended usagepaint(2); // ✅ 2 happens to equal Color.Blue's value, so the literal check allows it
declare const n: number; // any runtime number -- not a literal the checker can range-checkpaint(n); // ✅ still compiles -- no bounds check against a non-literal numberThis is the numeric enum’s real unsoundness: at the type level Color looks like a closed set of three values, but at runtime it’s just number, and any number is assignable.
const enum: inlined, and why it doesn’t survive real bundling
Section titled “const enum: inlined, and why it doesn’t survive real bundling”A const enum never emits an object at all — every reference is replaced by its literal value at the use site:
const enum Direction { Up, Down }console.log(Direction.Up);"use strict";console.log(0 /* Direction.Up */);That inlining is exactly what makes it break in a real multi-file build: tsc can do it because it type-checks the whole program at once and can see every const enum’s values. A single-file transpiler (esbuild, SWC, Babel, or tsc --isolatedModules handling ambient const enums from a .d.ts) processes one file in isolation and has no way to look up the value:
// @skip-verify illustrative -- the import target is a second file (a real .d.ts) in a genuine multi-file build; verified separately in scratch with two real files, see the quoted output belowimport { Direction } from "./somewhere"; // declared there as `export declare const enum Direction { ... }` in a .d.tsconsole.log(Direction.Up);Verified in scratch with two real files — a direction.d.ts declaring export declare const enum Direction { Up, Down } and a main.ts importing from it — npx tsc --ignoreConfig --noEmit --target es2025 --module esnext --isolatedModules main.ts gives:
main.ts(2,13): error TS2748: Cannot access ambient const enums when 'isolatedModules' is enabled.isolatedModules is effectively required by every modern bundler-based toolchain, so a const enum imported from a compiled dependency (rather than from source in the same program) is a real, common breakage — not a hypothetical.
TypeScript 7’s --erasableSyntaxOnly rejects enum outright
Section titled “TypeScript 7’s --erasableSyntaxOnly rejects enum outright”Because enums generate code, they fail the same “erasable syntax” test the erasure lesson uses for parameter properties. Run with the flag (the fixed-flags harness in this course can’t toggle it, so this is the real command and output from this machine):
$ npx tsc --ignoreConfig --noEmit --erasableSyntaxOnly --target es2025 enum.tsenum.ts(1,6): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.Both a plain enum and a const enum hit the identical TS1294 — const changes the emit strategy, not whether the syntax is erasable.
Node’s type-stripping rejects it the same way
Section titled “Node’s type-stripping rejects it the same way”node --experimental-strip-types enforces the same rule at the runtime level, independent of any TypeScript flag:
$ node --experimental-strip-types enum.tsSyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only modeThe replacement: an as const object plus keyof typeof
Section titled “The replacement: an as const object plus keyof typeof”The idiomatic erasable replacement is a plain object literal, frozen with as const, with the union type derived from its own values:
// @runconst Color = { Red: 0, Green: 1, Blue: 2,} as const;
type Color = typeof Color[keyof typeof Color]; // 0 | 1 | 2
function paint(c: Color) {}paint(Color.Green); // ✅console.log(Color.Green);// 1This survives erasure completely (it’s just a const and a type alias, both erasable), works under node --experimental-strip-types unmodified, and gives autocomplete on Color.___ the same as a real enum. What it does not give you is the reverse mapping — if you need value→name lookup, build it explicitly (Object.entries(Color)), since as const objects don’t generate one automatically.
as const object replacing an enum
enum Color { Red, Green, Blue }console.log(Color.Green); // 1console.log(Color[1]); // "Green" -- numeric reverse mapping
const ColorConst = { Red: 0, Green: 1, Blue: 2,} as const;type ColorConst = typeof ColorConst[keyof typeof ColorConst];
function paint(c: ColorConst) {}paint(ColorConst.Green); // erasable replacement -- same autocomplete, no runtime object needed Newer built-ins your lib already types (es2024 / es2025)
Section titled “Newer built-ins your lib already types (es2024 / es2025)”With target and lib at es2025 (the TypeScript 7 default), a lot of collection code that used to need a helper library is typed out of the box:
-
Set algebra (5.5):
a.union(b),intersection,difference,symmetricDifference,isSubsetOf,isSupersetOf,isDisjointFrom— all return correctly typedSets. -
Iterator helpers (5.6): generators and built-in iterators have
.map,.filter,.take,.drop,.flatMap,.reduce,.toArray, plusIterator.from(...). Their type isIteratorObject<T>(andAsyncIteratorObject<T>), distinct from theIteratorinterface you implement by hand:function* naturals() { let i = 1; while (true) yield i++; }const evens = naturals().map((n) => n * 2).take(3).toArray(); // number[]const userById = new Map<number, { email: string }>([]);const byEmail = new Map(userById.entries().map(([id, u]) => [u.email, id])); -
Grouping (es2024):
Object.groupBy(items, (x) => x.kind)returnsPartial<Record<K, T[]>>;Map.groupByreturns aMap. -
Upsert (6.0):
map.getOrInsert(key, fallback)andmap.getOrInsertComputed(key, () => …). -
Also in
es2025:RegExp.escape,Promise.try,Promise.withResolvers, and built-in Temporal types (Temporal.Now.instant()), so you no longer need extra@typesfor them.
Typed arrays are generic now (5.7)
Section titled “Typed arrays are generic now (5.7)”Uint8Array and friends became generic over their buffer — Uint8Array<TArrayBuffer extends ArrayBufferLike = ArrayBufferLike> — and in 5.7 ArrayBuffer stopped being a supertype of the typed arrays. The practical effect: passing a Uint8Array to a parameter typed ArrayBuffer no longer works the way it used to. Fix it by taking the .buffer when you own the API:
// @expect-error TS2345const data = new Uint8Array([1, 2, 3]); // Uint8Array<ArrayBuffer>function send(b: ArrayBuffer) {}send(data.buffer); // ✅ ArrayBuffer
// A plain Uint8Array (no buffer type argument) is Uint8Array<ArrayBufferLike> —// its .buffer can be a SharedArrayBuffer, so it still fails:declare const generic: Uint8Array;send(generic.buffer); // ❌ ArrayBufferLike is not assignable to ArrayBufferusing: scoped cleanup for resources (5.2)
Section titled “using: scoped cleanup for resources (5.2)”Explicit Resource Management — using and await using — shipped in TypeScript 5.2, and Node.js 24+ and modern browsers now run it natively. A value with [Symbol.dispose] (or [Symbol.asyncDispose]) is cleaned up when the block exits, in reverse order, even on throw:
import * as fs from "node:fs";
function openFile(path: string) { const fd = fs.openSync(path, "r"); return { fd, [Symbol.dispose]() { fs.closeSync(fd); } };}
function read() { using file = openFile("data.txt"); // closed automatically at block end return fs.readFileSync(file.fd);}Disposable and AsyncDisposable are the interfaces to implement, and DisposableStack collects several. Model resources this way instead of try/finally ladders.
Mental Model
Section titled “Mental Model”flowchart TD
v["A value flows toward a typed slot"] --> fresh{"Is it a fresh object literal,
written right at the call site?"}
fresh -->|"yes"| epc["Excess property check runs
(catches typos)"]
fresh -->|"no -- via a variable"| indirect["Only structural compatibility checked
(extra properties allowed)"]
v --> idx{"Indexing into an array/object
by a non-constant key?"}
idx -->|"noUncheckedIndexedAccess off"| trust["Trusted -- T, no undefined"]
idx -->|"on"| honest["Honest -- T | undefined"]
v --> lit{"Is it a number literal into an enum-typed slot?"}
lit -->|"yes, matches a member"| passes["Checked against real member values"]
lit -->|"no -- a plain number variable"| unchecked["Zero range checking -- any number passes"] Almost every topic in this lesson is a variation on the same question: how much does the compiler actually verify at this specific point? Excess property checks verify a lot, but only at the literal’s birth. noUncheckedIndexedAccess decides whether “reading past the end” is even a checked condition. A numeric enum’s parameter type looks airtight but only checks literals it can pattern-match against known members — a plain number variable walks straight through. None of these are bugs in the compiler; they’re the edges of what static analysis can promise, and knowing exactly where each edge is is what separates “the type system protects me” from “the type system protects me here, but not there.”
Trade-offs
Section titled “Trade-offs”| Feature | Benefit | Cost |
|---|---|---|
| Excess property check | Catches typos on object literals immediately | Only applies to fresh literals — one variable of indirection turns it off |
exactOptionalPropertyTypes | Distinguishes “absent” from “explicitly undefined” | Off by default even under strict; needs auditing existing code that relied on the loose behavior |
object/{} as a parameter type | Extremely permissive, easy to satisfy | Accepts far more than it looks like — primitives included for {} and Object |
noUncheckedIndexedAccess | Makes every index read honest about absence | An extra ` |
Real enum | Familiar syntax, reverse mapping for free (numeric) | Non-erasable — rejected by erasableSyntaxOnly and Node’s type-stripping; weak range checking against non-literal numbers |
as const object instead of enum | Fully erasable, works everywhere, same autocomplete | No automatic reverse mapping; you build it yourself if you need it |
Common Misconceptions
Section titled “Common Misconceptions”- A numeric enum is a closed, checked set of values. Only enum-member literals get checked against it. Any
number— fromparseInt, an API body,Math.random()— is assignable to an enum-typed parameter with no range check at all. const enumis just a fasterenum. It changes the emit strategy (inlined values instead of an object), which is exactly why it breaks underisolatedModuleswhen the values live in another compiled file — a real bundler hazard, not a performance footnote.Objectand{}are stricter thanobjectbecause they look more “specific.” The opposite:objectis the strict one (no primitives);Objectand{}accept almost everything non-nullish, primitives included.- An out-of-bounds array read is caught by
strictalone. It isn’t —strictdoesn’t includenoUncheckedIndexedAccess. Without that separate flag,arr[999]is typed as if it always succeeds.
Reading AI-generated code
Section titled “Reading AI-generated code”Before trusting generated object, array, tuple, or enum code, check for:
- A function parameter typed with a numeric
enumthat receives a value from an untyped boundary (req.body,JSON.parse, a database row) without validating membership first — the type system won’t catch an out-of-range number there. - A
switchover every enum member with nodefault, treated as proof the function always returns — it’s exhaustive for values the compiler can prove are valid members, not for whatever a caller passes through a widened parameter. - Object literals passed through a helper (
Object.assign, a spread, a factory function) before reaching a strictly-typed parameter — the excess property check that would have caught a typo at the literal never gets a chance to run. - Index access on an array or
Recordby a computed key, with no visible narrowing afterward and nonoUncheckedIndexedAccessin the project’stsconfig.json— a likely silent “possibly undefined.”
🐛 Spot the bug
enum Status { Pending, Active, Closed }
function label(s: Status): string {switch (s) { case Status.Pending: return "pending"; case Status.Active: return "active"; case Status.Closed: return "closed";}}
function fromApi(rawStatus: number): string {return label(rawStatus); // rawStatus came from an API response body -- any number}
console.log(fromApi(999).toUpperCase());Show the answer
This compiles with zero errors, for two independent reasons stacking on top of each other. First, label’s switch covers every Status member, so TypeScript accepts it as exhaustive with no default needed — but that exhaustiveness is only a promise about real Status values. Second, fromApi accepts a plain number and passes it straight to label(s: Status), and a numeric enum’s parameter type has no range check against a non-literal number — rawStatus sails through untouched. At runtime, label(999) matches no case, falls off the end of the switch, and returns undefined from a function declared to return string. The real crash is TypeError: Cannot read properties of undefined (reading 'toUpperCase'). The fix is to validate at the boundary — either an explicit default: throw new Error(...) inside label, or checking rawStatus in Status (or against Object.values(Status)) before ever calling label.