Objects and Type Aliases
Describing the shape of your data, naming that shape so it can be reused, optional and readonly properties, and why an object type is a contract rather than a class.
Describing the shape of your data, naming that shape so it can be reused, optional and readonly properties, and why an object type is a contract rather than a class.
You are passing four values into every function — a name, a city, a size, a date — because they describe one photograph and travel together everywhere. Then someone adds a fifth field and you edit eleven function signatures.
They travel together because they are one thing. An object lets you say so. By the end of this lesson you will describe the shape of your data, give that shape a name, and understand a checking rule that surprises everyone the first time it lets something through.
const photo = {
name: "dawn.jpg",
city: "Lisbon",
size: 1450,
};
console.log(photo.name); // dawn.jpg
console.log(photo.size); // 1450An object groups values under names. Each key: value pair
is a property, and you reach one with a dot.
The checker inferred a type from what you wrote:
{ name: string; city: string; size: number }So the mistakes are caught:
photo.nmae;
// Property 'nmae' does not exist. Did you mean 'name'?
photo.size = "big";
// Type 'string' is not assignable to type 'number'.
photo.caption = "Dawn";
// Property 'caption' does not exist on this type.That last one is worth pausing on. An object's shape is fixed by its type — you cannot add properties that were not part of it. That is the whole point: a typo becomes an error rather than a new property nobody notices.
Writing that type inline gets repetitive. A type alias names it:
type Photo = {
name: string;
city: string;
size: number;
};
const photo: Photo = {
name: "dawn.jpg",
city: "Lisbon",
size: 1450,
};
function caption(photo: Photo): string {
return `${photo.name}, ${photo.city}`;
}Now the shape exists once. Adding a field is one edit, and every place that must change is reported.
Type names are conventionally capitalised. A type alias creates
no value and no code — it is deleted along with everything else
at compile time, so a type costs nothing at runtime.
Optional and read-only properties:
type Photo = {
readonly id: string; // cannot be reassigned after creation
name: string;
caption?: string; // may be absent: string | undefined
};readonly is worth using freely on identifiers and anything set
once. It is checked at compile time only, so it documents intent
and catches accidents rather than enforcing anything at runtime.
type Photo = {
name: string;
location: {
city: string;
country: string;
};
tags: string[];
};photo.location.city; // "Lisbon"
photo.tags[0]; // the first tagFor anything nested more than one level, name the inner shape too:
type Location = {
city: string;
country: string;
};
type Photo = {
name: string;
location: Location;
};Now Location can be used on its own, appear in a function
signature, and be understood without reading the outer type.
Objects are mutable, and the same const rule as arrays
applies:
const photo: Photo = { name: "dawn.jpg", city: "Lisbon", size: 1450 };
photo.size = 1600; // fine - the name still points here
photo = otherPhoto; // error - repointing the nameTo produce a changed version rather than modifying, spread:
const larger = { ...photo, size: 1600 };That builds a new object with every property of photo, then
overrides size. Later properties win, which is what makes this
work.
And the trap that follows from it:
const backup = photo; // NOT a copy - a second name
backup.size = 9999;
console.log(photo.size); // 9999Assigning an object does not copy it. Both names refer to the
same object, so a change through one is visible through the
other. { ...photo } makes a copy — but only one level deep:
const copy = { ...photo };
copy.location.city = "Porto";
console.log(photo.location.city); // "Porto" - sharedThe nested location is still the same object.
All the way down. Nothing is shared with the original.
One level. The top-level properties are new; anything nested is still the same object.
Not a copy at all. One object, two names — and a change through either is visible through both.
Pulling properties into names of their own:
const { name, city } = photo;
console.log(name); // "dawn.jpg"function caption({ name, city }: Photo): string {
return `${name}, ${city}`;
}The second form is common in function parameters — you name what
you need and the body reads without photo. on every line.
Renaming and defaults:
const { name: filename, caption = "Untitled" } = photo;name: filename puts the name property into a name called
filename. caption = "Untitled" supplies a value when the
property is undefined.
And the rest, collecting what you did not name:
const { name, ...everythingElse } = photo;Here is the rule that surprises everyone.
type Photo = { name: string; size: number };
const candidate = {
name: "dawn.jpg",
size: 1450,
caption: "Dawn over Lisbon", // an extra property
};
const photo: Photo = candidate; // allowedA value with more properties than the type requires is accepted. This is structural typing: TypeScript asks whether the value has everything the type needs, not whether it matches exactly. Extra properties are not the type's problem.
But this is an error:
const photo: Photo = {
name: "dawn.jpg",
size: 1450,
caption: "Dawn",
// Object literal may only specify known properties.
};An object written directly where a type is expected gets an excess property check, because a literal with an unexpected key is nearly always a typo. Assign it to a name first and the check does not apply.
This matters most in a specific case:
Bad — a misspelled optional property, assigned through a variable.
type Options = { limit?: number; dryRun?: boolean };
const options = { limit: 50, dryRUN: true };
process(options); // accepted. dryRun is never set.Good — the object written where the type is expected.
process({ limit: 50, dryRUN: true });
// Object literal may only specify known properties.
// Did you mean to write 'dryRun'?The same typo, two ways of passing it, two completely different outcomes.
Accepted. Silently.
Every property in Options is optional, so { limit: 50 }
alone satisfies the type — and dryRUN is just an extra the
checker does not care about.
The function runs with dryRun undefined, takes the
destructive path, and nothing reported anything.
Error, naming the typo.
A literal with an unexpected key is nearly always a mistake, so this position gets the excess property check.
Did you mean to write 'dryRun'?
The habit that avoids it: pass object literals directly, or
annotate the variable — const options: Options = {...} — which
brings the check back.
Sometimes the keys are data rather than a fixed set:
const counts: Record<string, number> = {};
counts["sunrise"] = 3;
counts["tram"] = 1;Record<string, number> means "any string keys, all values are
numbers". The equivalent longhand is an index signature:
type Counts = {
[tag: string]: number;
};Use this only when the keys genuinely are not known in advance — a tally, a lookup built at runtime. For a fixed set of fields, name them, because an index signature gives up the typo checking that is most of the value.
And note that reading a key gives you the value type even when the key is absent:
counts["missing"].toFixed(); // compiles, crashesnoUncheckedIndexedAccess fixes this too, the same way it fixed
array positions.
OBJECTS
const photo = { name: "a.jpg", size: 1450 };
photo.name dot access
a typo is an ERROR, not a new property
TYPE ALIASES
type Photo = { name: string; size: number };
capitalised by convention; erased at compile time
readonly id: string cannot be reassigned
caption?: string may be absent
NESTING
name the inner shape once it is more than one level
COPYING
const b = a; NOT a copy - one object, two names
{ ...photo } a copy, ONE level deep
{ ...photo, size: 1600 } a changed copy <- prefer
structuredClone(photo) all the way down
DESTRUCTURING
const { name, city } = photo;
function f({ name }: Photo) in parameters
const { name: filename } = photo; rename
const { caption = "Untitled" } = photo; default for undefined
const { name, ...rest } = photo;
STRUCTURAL TYPING
extra properties are fine - the type asks what it NEEDS
BUT an object literal written where a type is expected gets
an excess property check, because a stray key is a typo
const o = { dryRUN: true }; process(o); accepted - silent bug
process({ dryRUN: true }); error, names the typo
pass literals directly, or annotate the variable
UNKNOWN KEYS
Record<string, number> when keys are data
{ [tag: string]: number } the longhand
gives up typo checking - use only when keys are unknown
reading a missing key compiles; noUncheckedIndexedAccess fixes itYou can now describe the shape of your data, name it, copy it safely, and pull it apart. The rule worth remembering is the excess property check — it is the difference between a typo caught immediately and an optional flag that silently never takes effect.
Next is Functions, Parameters, and Return Types, which is where annotations genuinely earn their place. Everything so far has been inferred; a function's inputs have no value to look at, so they are the one thing you must describe.
Before you move on, reproduce the excess property bug. Define a type where every property is optional, build an object with a misspelled key, assign it to a variable and pass it to a function. Then pass the same literal directly and watch the error appear. That difference is invisible until you have caused it once.