Skip to content

Objects, Arrays & Tuples

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
}
  • readonly blocks 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 type T | undefined, so you must handle the undefined case:
// @expect-error TS18048
interface 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 TS2561
interface 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 typo
create(o); // ✅ no error -- o is not a fresh literal anymore

This 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 today

Run 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 the
target'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 TS2322
let a: object;
let b: Object;
let c: {};
a = { x: 1 }; b = { x: 1 }; c = { x: 1 }; // ✅ all three
a = 5; // ❌ object explicitly excludes primitives
let 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 Object
  • object (lowercase, introduced specifically for this) means “anything that isn’t a primitive” — the true non-primitive type.
  • Object is the interface with toString, 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 TS2339
function 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 | undefined
console.log(x.toFixed(2)); // compiles -- crashes at runtime

Turning 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.

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 omitted
const p2: Point = [1, 2, 3]; // ✅ z provided
type Padded = [start: number, ...middle: string[], end: boolean]; // rest in the middle
const 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 tuple

An 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).

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 value

The 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.

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 number
let point: [number, number] = [10, 20]; // exactly two numbers
let pair: [string, number] = ["age", 36]; // position 0 string, position 1 number

Tuples 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]"]
Array is homogeneous and variable; tuple is positional and fixed

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.

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 value
console.log(Color[1]); // "Green" -- reverse: value to name

Enums 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.

A string enum only assigns forward, so indexing by value doesn’t work — and the emitted object proves it:

// @expect-error TS2551
enum 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 TS2345
enum Color { Red, Green, Blue }
function paint(c: Color) {}
paint(999); // out of range -- no member has this value

But 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 usage
paint(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-check
paint(n); // ✅ still compiles -- no bounds check against a non-literal number

This 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 below
import { Direction } from "./somewhere"; // declared there as `export declare const enum Direction { ... }` in a .d.ts
console.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.ts
enum.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.ts
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode

The 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:

// @run
const 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);
// 1

This 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); // 1
console.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
Open in TS Playground

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 typed Sets.

  • Iterator helpers (5.6): generators and built-in iterators have .map, .filter, .take, .drop, .flatMap, .reduce, .toArray, plus Iterator.from(...). Their type is IteratorObject<T> (and AsyncIteratorObject<T>), distinct from the Iterator interface 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 }>([
    [1, { email: "[email protected]" }],
    [2, { email: "[email protected]" }],
    ]);
    const byEmail = new Map(userById.entries().map(([id, u]) => [u.email, id]));
  • Grouping (es2024): Object.groupBy(items, (x) => x.kind) returns Partial<Record<K, T[]>>; Map.groupBy returns a Map.

  • Upsert (6.0): map.getOrInsert(key, fallback) and map.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 @types for them.

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 TS2345
const 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 ArrayBuffer

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.

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"]
Every check in this lesson is 'how much does the compiler actually verify here?'

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.”

FeatureBenefitCost
Excess property checkCatches typos on object literals immediatelyOnly applies to fresh literals — one variable of indirection turns it off
exactOptionalPropertyTypesDistinguishes “absent” from “explicitly undefined”Off by default even under strict; needs auditing existing code that relied on the loose behavior
object/{} as a parameter typeExtremely permissive, easy to satisfyAccepts far more than it looks like — primitives included for {} and Object
noUncheckedIndexedAccessMakes every index read honest about absenceAn extra `
Real enumFamiliar 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 enumFully erasable, works everywhere, same autocompleteNo automatic reverse mapping; you build it yourself if you need it
  • A numeric enum is a closed, checked set of values. Only enum-member literals get checked against it. Any number — from parseInt, an API body, Math.random() — is assignable to an enum-typed parameter with no range check at all.
  • const enum is just a faster enum. It changes the emit strategy (inlined values instead of an object), which is exactly why it breaks under isolatedModules when the values live in another compiled file — a real bundler hazard, not a performance footnote.
  • Object and {} are stricter than object because they look more “specific.” The opposite: object is the strict one (no primitives); Object and {} accept almost everything non-nullish, primitives included.
  • An out-of-bounds array read is caught by strict alone. It isn’t — strict doesn’t include noUncheckedIndexedAccess. Without that separate flag, arr[999] is typed as if it always succeeds.

Before trusting generated object, array, tuple, or enum code, check for:

  • A function parameter typed with a numeric enum that 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 switch over every enum member with no default, 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 Record by a computed key, with no visible narrowing afterward and no noUncheckedIndexedAccess in the project’s tsconfig.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.

What is the type of an optional property email?: string?
What does the noUncheckedIndexedAccess option change?
What is the difference between number[] and [number, number]?
What type does [255, 128, 0] as const produce?
What type do iterator helper methods such as .map and .take return in TypeScript 5.6+?
Does this compile?
interface Options { width: number; height?: number }
function create(opts: Options) {}

const o = { width: 100, heigth: 200 };
create(o);
Which assignments compile?
let a: object;
let b: Object;
let c: {};

a = 5;
b = 5;
c = 5;
Does fromApi compile, and is it safe to call with any number?
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);
}