TypeScript Survival Guide

Course manual

Objects

Chapter 03 — Objects: modeling the domain

Diverse teenage scouts practicing courteous introductions in a sunny meadow campsite

Most values worth checking are objects: knots with names and loop counts, hosts with accents and shapes, tiers with settings. This chapter covers how TypeScript describes object shapes — object types, interfaces, and aliases — how to mark fields optional, read-only, or open-ended, how to extend and compose shapes, and the structural rule that makes it all work. The lab has you model the knot rack as a domain.

Examples lead with trail knot shapes, then show the production cuts they mirror: host configs and the host pool from the Sumerian Squares stage, where the same four techniques describe a shipping 3D cast.

Object types, interfaces, aliases

An object type lists each field and its type:

export type KnotEntry = {
	name: string;
	use: string;
	material: string;
	loops: number;
};

An interface declares the same shape with a name the compiler can extend and error messages can quote:

export interface Knot {
	name: string;
	use: string;
	material: string;
	loops: number;
}

A type alias names any type — object, union, literal, or combination — so it can be reused and discussed: export type Loops = number | null;. Course convention: interfaces for object shapes that represent domain things (knots, troop records, configs), aliases for unions, literals, and compositions. Either can describe an object; the difference that matters is that interfaces extend (see below) while aliases compose with &. Pick by that need, not by habit.

New here: object types, interfaces, aliases. Docs: Object Types and Interfaces. Watch: 015 — Interfaces (a contract is not a class: no constructor, never generates objects — it only declares what future variables, objects, and function parameters must satisfy), then 093 — Interfaces in Practice (“say I have a constant called me and I’m gonna set that type to be IsPerson” — the contract applied to a value, then to a function parameter), and 104 — Type vs Interface for the course’s own convention under fire — Kyle: the handbook is “completely backwards because interfaces are more complicated than types, they have less features than types.” Know his argument; the course still picks interfaces for domain things, and now you know exactly what that choice costs.

Production code follows the same split — interfaces for the domain things, aliases for the closed vocabularies:

// cut verbatim from the Sumerian Squares stage sources (sister repo)
export type HostId =
	| "cristine"
	| "fiona"
	| "grace"
	| "maya"
	| "alien"
	| "luke"
	| "jay"
	| "preston"
	| "wes";

export interface HostConfig {
	readonly id: HostId;
	readonly position: number; // 1-9 grid position
	readonly archetype: string;
	readonly gltfUrl: string;
	readonly hostType: HostType;
}

HostId is an alias over a nine-member literal union — the closed set of performers. HostConfig is an interface using it: each host carries its grid position, archetype name, model URL, and kind. Your knot model will look like this by the end of the lab.

Optional, readonly, index signatures

Three modifiers adjust fields. Optional (loops?: number) marks a field the value may omit — a coil before its loops are counted. Reading it gives number | undefined, so the compiler forces the check. Keep optionals honest: only fields genuinely absent some of the time earn the ?.

readonly marks a field assignable once, at creation, and never again. Knot entries, host configs, and tier settings are all read-only after build — mutation is where shared references rot, so the course marks domain data readonly by default and drops the modifier only where mutation is the point.

Index signatures describe open-ended dictionaries: objects whose keys are not known up front but whose values share a type:

export interface CampGrades {
	readonly [name: string]: number;
}

const grades: CampGrades = { Bowline: 95, "Clove hitch": 92 };

Use index signatures for lookup tables keyed by runtime data (names, host ids). Prefer explicit fields when the keys are known — a typo in a known key should be a compile error, not a silent new entry. A Record<HostId, PooledHost> would demand all nine hosts up front with a missing host failing the build; production code reaches for the runtime cousin instead — const pool = new Map<HostId, PooledHost>() in host-pool.ts — because the pool evicts and refills while the stage runs. Same closed vocabulary as the key (HostId), different container for a different lifetime: Record for fixed tables built once, Map for living collections. (An earlier draft of this page claimed a per-host accent palette type here; no such type exists in the shipping sources, so the claim is gone. Verify, then trust.)

New here: optional, readonly, index signatures, Record. Docs: Object Types (readonly modifiers, index signatures). Watch 121 — Utility Types for Record in its natural habitat — Chapter 07 runs the full utility survey, but read the Record segment now so the knot table in the lab arrives pre-solved.

Extending and composing

Shapes grow two ways. Interfaces extend: a Hitch is a Knot with more fields, declared once, inheriting the rest:

export interface Hitch extends Knot {
	wraps: number;
	locked: boolean;
}

Aliases and interfaces both compose with intersections: Hitch & Anchor is a value satisfying both shapes at once. Prefer extends for is-a-more-specific hierarchies (hitch is a knot), & for combining independent capabilities (a hitch that is also an anchor, where neither shape owns the other). Both keep one definition per concept — rename a field on Knot and every extension and composition follows.

New here: extends, intersections. Docs: Interfaces (extending) and Everyday Types (intersections). Drill 2 below is the whole lesson in four lines: extends with a conflicting field refuses (TS2430), while the & version compiles into a shape no value can satisfy. Extending fails loud at the declaration; intersecting fails silent at every use. Prefer the loud one.

Structural typing

TypeScript checks shape, not origin. Any object with the right fields satisfies the type, regardless of where it was built or what else it carries:

export function label(k: Knot): string {
	return `${k.name} holds ${k.loops} loops`;
}

label({ name: "Bowline", use: "loop", material: "nylon", loops: 2 });
const hitch = {
	name: "Clove hitch",
	use: "hitch",
	material: "nylon",
	loops: 2,
	wraps: 3, // extra fields ride along through a variable... see note
};
label(hitch);

The rule is “at least these fields with these types.” This is why test doubles, parsed JSON, and hand-built fixtures slide into typed functions without ceremony: if it has the shape, it is the shape. One sharp edge to memorize: a fresh object literal passed directly is checked for excess fields (a typo like jersy: 12 is caught), while the same object through a variable is not — the checker assumes a stored value may legitimately carry more. Excess-property checks catch misspellings at the call; structural acceptance keeps composition painless everywhere else.

New here: structural typing, excess-property checks. Docs: Object Types (excess property checks) and Type Compatibility. Drill 4 is the rule in three calls: fresh literal with extras refuses, stored variable with extras passes, missing required field always refuses. “At least these fields with these types” — the sentence the whole chapter hangs on.

DIY simulator drills

You are the simulator. Cover the answers, predict, then verify. Log each miss in one sentence. Run one drill per file — the drills reuse the name Knot, and same-named interfaces in one program merge into a single shape (declaration merging). That merge is compiler behavior, not a drill artifact; keep the drills apart so you test what each one claims.

  1. Be the checker. Under strict: true, which lines error?

    interface Knot {
    	name: string;
    	loops?: number;
    	readonly material: string;
    }
    const a: Knot = { name: "Bowline", material: "nylon" };
    const b: Knot = { name: "Bowline", material: "nylon", jersy: 12 };
    const c = { name: "Bowline", material: "nylon", jersy: 12 };
    const d: Knot = c;
    a.material = "poly";
    

    Verify with npx tsc --noEmit. (The jersy line refuses, the lookalike through a variable passes, and one more line refuses for an entirely different reason. Name all three mechanisms.)

  2. Be the extender. Which declarations compile?

    interface Knot {
    	name: string;
    	loops: number;
    }
    interface Hitch extends Knot {
    	loops: string;
    }
    type Anchor = Knot & { loops: string };
    

    Verify with npx tsc --noEmit. (Extending narrows nothing — a conflicting field is a flat refusal, while the intersection compiles into something no value can ever satisfy. Say which is which, and which failure you would rather debug.)

  3. Be the indexer. Which table compiles, and which lies?

    type Crew = "aiko" | "zola";
    const t1: Record<Crew, number> = { aiko: 3 };
    const t2: { [name: string]: number } = { aiko: 3 };
    t2.zola = 5;
    

    Verify with npx tsc --noEmit, then run both tables in node (strip the types) and read t2. (The closed table refuses an absent member at compile time; the open one accepts everything and proves nothing. Match each to its production cousin from this chapter: Record for fixed tables, Map for living collections.)

  4. Be the structuralist. What does label accept?

    interface Knot {
    	name: string;
    	loops: number;
    }
    function label(k: Knot): string {
    	return `${k.name}: ${k.loops}`;
    }
    label({ name: "Hitch", loops: 2, wraps: 3 });
    const h = { name: "Hitch", loops: 2, wraps: 3 };
    label(h);
    label({ name: "Hitch" });
    

    Verify with npx tsc --noEmit. (Fresh literal, stored variable, missing field — three receptions for three shapes. State the rule in one sentence before reading on.)

Lab: model the knot domain

The domain-model kit gives you a flat knot list of loosely typed rows and a red suite describing the domain: a Knot interface with readonly fields, an optional repair note, a use literal union, a Record<Use, string[]> knot table, and a lookup whose misses return undefined. Write the Knot interface, implement the label, the lookup, and the table, and watch the suite go green. The final test passes a hitch object carrying an extra slips flag into label(k: Knot) — it passes because structure decides and origin does not matter.

Resources

Docs, in the order the chapter introduces them:

Videos — the chapter’s shelf, section-mapped (quoted lines are the instructor’s own words as preserved in each companion entry):

Hands-on: drill set above (one file per drill, predict first, tools second), then the domain-model kit — Knot interface, lookup with undefined misses, Record<Use, string[]> table, and the hitch-through-label finale.