Objects
An object is a set of slots, each with a key and a value. Keys are strings or symbols and nothing else, the fact the symbols page hangs on. The part worth appreciating, especially arriving from a language where every object needs a class first: you can just write one down. An object literal is a value like any other, so a function can return a shape without a name for it, and most of the time that is the right amount of ceremony.
There are two ways to use one. As a record with a shape known while you write the code, which is this entry. Or as a lookup table whose keys arrive at run time, which is the objects as dictionaries page, because the questions are different enough to separate.
Create programs/objects.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:
import {
assert,
assertEquals,
assertFalse,
assertStrictEquals,
assertThrows,
} from "@std/assert";
Follow the page as you add and revise the runnable examples below that import.
a literal holds values, methods, and accessors
Deno.test("a literal holds values, methods, and accessors", () => {
const ada = {
first: "Ada",
last: "Lovelace",
says(text: string): string {
return `${this.first} says "${text}"`;
},
get full(): string {
return `${this.first} ${this.last}`;
},
set full(fullName: string) {
const parts = fullName.split(" ");
this.first = parts[0];
this.last = parts[1];
},
};
assertStrictEquals(ada.first, "Ada");
assertStrictEquals(ada.says("hello"), 'Ada says "hello"');
assertStrictEquals(ada.full, "Ada Lovelace");
ada.full = "Grace Hopper";
assertStrictEquals(ada.first, "Grace");
assertStrictEquals(ada.last, "Hopper");
});
Check programs/objects.test.ts
running 1 test from ./programs/objects.test.ts
a literal holds values, methods, and accessors ... ok (0ms)
ok | 1 passed | 0 failed (1ms)
Four kinds of entry in one literal. first is a value. says is a method, which is a property whose value is a function, and this inside it is the receiver from the value of this. And full is an accessor pair: a get that runs when the property is read, and a set that runs when it is written.
The accessors are the interesting ones, because they are indistinguishable from a property at the call site. ada.full looks like data and is a function call, and ada.full = "Grace Hopper" looks like an assignment and runs the setter, which is why first and last hold new values afterwards.
a shorthand, and reading what is not there
Deno.test("a shorthand, and reading what is not there", () => {
function createPoint(x: number, y: number): { x: number; y: number } {
return { x, y };
}
assertEquals(createPoint(9, 2), { x: 9, y: 2 });
const point = createPoint(1, 1) as { x: number; z?: number };
assertStrictEquals(point.z, undefined);
});
a shorthand, and reading what is not there ... ok (1ms)
Two small facts. When the key and the variable have the same name, { x, y } writes it once and means { x: x, y: y }. And reading an absent property produces undefined rather than an error, which is the language decision that makes optional chaining necessary when we get to optional chaining stops at the first nothing.
a getter with no setter is read-only, and says so
Deno.test("a getter with no setter is read-only, and says so", () => {
function createCounter() {
let value = 0;
return {
get value(): number {
return value;
},
inc(): void {
value++;
},
};
}
const counter = createCounter();
assertStrictEquals(counter.value, 0);
counter.inc();
assertStrictEquals(counter.value, 1);
assertThrows(
() => {
(counter as { value: number }).value = 5;
},
TypeError,
"Cannot set property value of #<Object> which has only a getter",
);
});
a getter with no setter is read-only, and says so ... ok (0ms)
The counter's actual value lives in a closure, the pattern the closures page built in privacy that needs no keyword, so nothing outside can reach it, and the getter exposes it for reading only. Writing throws, with a message that names the reason precisely. This is the honest way to have a property that can be observed and not set. Note the cast in the test: TypeScript already knows value has no setter and refuses the assignment, so the cast is what it takes to reach the run-time behavior at all.
spreading, and which mention wins
Deno.test("spreading, and which mention wins", () => {
const base = { one: 1, two: 2 };
assertEquals({ ...base, one: true }, { one: true, two: 2 });
const DEFAULTS = { alpha: "a", beta: "b" };
const provided = { alpha: "1" };
assertEquals({ ...DEFAULTS, ...provided }, { alpha: "1", beta: "b" });
const clobbered = { one: true, ...base };
assertStrictEquals(clobbered.one, 1);
const target = { a: 1 };
const result = Object.assign(target, { b: 2 }, { c: 3, b: true });
assertEquals(result, { a: 1, b: true, c: 3 });
assertStrictEquals(result, target);
});
Check programs/objects.test.ts
TS2783 [ERROR]: 'one' is specified more than once, so this usage will be overwritten.
const clobbered = { one: true, ...base };
~~~~~~~~~
at file:///programs/objects.test.ts:84:25
TS2785 [ERROR]: This spread always overwrites this property.
const clobbered = { one: true, ...base };
~~~~~~~
at file:///programs/objects.test.ts:84:36
error: Type checking failed.
A spread copies another object's entries into this literal, and when keys clash the last mention wins. That single rule gives you both of spreading's real uses: override after a spread and you have changed a property without mutating the original, spread defaults first and then the provided values, and missing properties fall back. Order is therefore load-bearing, and the clobbered line got it backwards, which the checker caught with the pair above: your hand-written one: true can never win against the spread behind it, which is almost never what anyone means. Two spreads in a row draw no complaint, since neither is statically known to clash. Pin it to watch the overwrite happen:
Deno.test("spreading, and which mention wins", () => {
const base = { one: 1, two: 2 };
assertEquals({ ...base, one: true }, { one: true, two: 2 });
const DEFAULTS = { alpha: "a", beta: "b" };
const provided = { alpha: "1" };
assertEquals({ ...DEFAULTS, ...provided }, { alpha: "1", beta: "b" });
// @ts-expect-error: 'one' is specified more than once, so this usage will be overwritten.
const clobbered = { one: true, ...base };
assertStrictEquals(clobbered.one, 1);
const target = { a: 1 };
const result = Object.assign(target, { b: 2 }, { c: 3, b: true });
assertEquals(result, { a: 1, b: true, c: 3 });
assertStrictEquals(result, target);
});
spreading, and which mention wins ... ok (0ms)
clobbered.one is 1; the explicit true never survived. Object.assign at the bottom is the same left-to-right idea done to an existing object: it hands back the object it mutated rather than a new one, which the last assertion pins. Useful when you must mutate in place, and spreading is what you want the rest of the time.
a copy made by spreading is shallow
A spread copy, one change to a string property, one push into a nested array. Predict what the original holds:
Deno.test("a copy made by spreading is shallow", () => {
const original = { id: "e1fd960b", values: ["a", "b"] };
const shallow = { ...original };
shallow.id = "changed";
assertStrictEquals(original.id, "e1fd960b");
shallow.values.push("x");
assertEquals(original.values, ["a", "b"]);
});
Check programs/objects.test.ts
running 5 tests from ./programs/objects.test.ts
...
a copy made by spreading is shallow ... FAILED (8ms)
ERRORS
a copy made by spreading is shallow => ./programs/objects.test.ts:94:11
error: AssertionError: Values are not equal.
[Diff] Actual / Expected
[
"a",
"b",
- "x",
]
FAILURES
a copy made by spreading is shallow => ./programs/objects.test.ts:94:11
FAILED | 4 passed | 1 failed (10ms)
error: Test failed
The push reached the original. The entries are copied; the values are not. So the string property is genuinely independent, which the passing id assertion proved, and the nested array is shared, which the "x" in the diff proves. This is the single most common bug involving objects, and it has nothing to do with objects being special: it is the values and references page showing up in a new place, because the copy holds the same reference the original held, and the difference is invisible until something mutates. Correct the prediction and add the deep version:
Deno.test("a copy made by spreading is shallow", () => {
const original = { id: "e1fd960b", values: ["a", "b"] };
const shallow = { ...original };
shallow.id = "changed";
assertStrictEquals(original.id, "e1fd960b");
shallow.values.push("x");
assertEquals(original.values, ["a", "b", "x"]);
const deep = structuredClone(original);
deep.values.push("y");
assertEquals(original.values, ["a", "b", "x"]);
assertEquals(deep.values, ["a", "b", "x", "y"]);
});
a copy made by spreading is shallow ... ok (0ms)
structuredClone copies deeply and is built in, and the original stays untouched this time. It also drops things quietly, which the designing error types page documents in what survives a boundary: a user-defined class instance arrives as a plain object with its methods gone.
the runtime spreads anything; the checker does not
Deno.test("the runtime spreads anything; the checker does not", () => {
assertEquals(
{ ...("oak" as unknown as Record<string, unknown>) },
{ 0: "o", 1: "a", 2: "k" },
);
assertEquals(
{ ...([1, 2] as unknown as Record<string, unknown>) },
{ 0: 1, 1: 2 },
);
// @ts-expect-error: Spread types may only be created from object types.
assertEquals({ ...null }, {});
// @ts-expect-error: Spread types may only be created from object types.
assertEquals({ ...123 }, {});
});
the runtime spreads anything; the checker does not ... ok (0ms)
Spreading null or a number gives an empty object rather than an error, and spreading a string or an array gives you index keys. All harmless, all almost certainly a mistake, and all refused before they run: the two pins carry TS2698, and the first two lines need the double cast to reach the behavior at all, which is the point. In checked code this is a thing you have to work at to do.
a symbol-keyed property is spread but not listed
Deno.test("a symbol-keyed property is spread but not listed", () => {
const symbolKey = Symbol("symbolKey");
const mixed = { stringKey: 1, [symbolKey]: 2 };
assertEquals(Object.keys(mixed), ["stringKey"]);
assertStrictEquals({ ...mixed }[symbolKey], 2);
});
a symbol-keyed property is spread but not listed ... ok (0ms)
An asymmetry worth knowing. Object.keys and friends skip symbol keys, the split the objects as dictionaries page mapped in four listing operations, four answers, and spreading does not skip them, so a symbol-keyed property survives a copy while staying invisible to anything that enumerates. That is usually what you want, since symbol keys exist to stay out of the way, and it will surprise you once.
optional chaining stops at the first nothing
Deno.test("optional chaining stops at the first nothing", () => {
type Person = { name: string; address?: { street?: { name: string } } };
const people: Person[] = [
{ name: "Ernie", address: { street: { name: "Sesame Street" } } },
{ name: "Bert", address: {} },
{ name: "Oscar" },
];
assertEquals(
people.map((p) => p.address?.street?.name),
["Sesame Street", undefined, undefined],
);
assertEquals(
people.map((p) => p.address?.street?.name ?? "(no name)"),
["Sesame Street", "(no name)", "(no name)"],
);
type Nested = { a: { b: { m: () => string } } };
function invokeM(value: Nested | undefined): string | undefined {
return value?.a.b.m();
}
assertStrictEquals(invokeM({ a: { b: { m: () => "result" } } }), "result");
assertStrictEquals(invokeM(undefined), undefined);
});
optional chaining stops at the first nothing ... ok (0ms)
a?.b reads b if a is neither null nor undefined, and otherwise produces undefined. Paired with ?? it becomes "reach in, and fall back if you cannot", and the nothing, twice page's ?? treats only null and undefined as missing is why ?? is the right partner and || is not. The mnemonic for the punctuation: if there is something, then access. Question mark, then dot.
What makes it more than shorthand is invokeM. Its body is value?.a.b.m(), with one question mark, and when value is undefined the entire rest of the chain stops, so .a on undefined never happens even though nothing after the first link is optional. An ordinary operator evaluates all its operands; ?. does not, which puts it in the same short-circuit family as &&, ||, and the conditional operator.
two more forms, and what ?.() refuses
Deno.test("two more forms, and what ?.() refuses", () => {
const base = { one: 1, two: 2 };
const key = "one";
assertStrictEquals(base?.[key], 1);
assertStrictEquals(
(undefined as unknown as typeof base)?.[key],
undefined,
);
function maybeCallback(): (() => string) | undefined {
return undefined;
}
assertStrictEquals(maybeCallback()?.(), undefined);
assertThrows(
() => (true as unknown as (() => void) | undefined)?.(),
TypeError,
"is not a function",
);
});
two more forms, and what ?.() refuses ... ok (0ms)
?.[] for a computed key and ?.() for a call. The dots look redundant in both, and they are there because obj?[x] could not be told apart from the conditional operator without more parsing than it is worth. The pinned TypeError at the end is a design choice rather than an oversight: ?.() tolerates undefined and null, and throws on anything else that is not callable, because a true where a function should be is a bug, and reporting it beats papering over it.
The argument against reaching for ?. everywhere: a long chain encodes the shape of several objects at every use site, so a rename touches all of them, and being forgiving about missing data hides the problem until somewhere far away. A typo early in a chain of optional names costs more than an ordinary typo, because nothing fails. The alternative is to extract the data once, in one function that checks properly and returns a normalized shape.
freezing is real, and only one level deep
Object.freeze throws on the direct write below, so predict what happens to the array inside:
Deno.test("freezing is real, and only one level deep", () => {
const frozen = Object.freeze({ id: "a", values: ["x"] });
assertThrows(
() => {
(frozen as { id: string }).id = "b";
},
TypeError,
"Cannot assign to read only property 'id' of object '#<Object>'",
);
assertThrows(() => frozen.values.push("y"), TypeError);
});
Check programs/objects.test.ts
running 10 tests from ./programs/objects.test.ts
...
freezing is real, and only one level deep ... FAILED (0ms)
ERRORS
freezing is real, and only one level deep => ./programs/objects.test.ts:182:11
error: AssertionError: Expected function to throw.
FAILURES
freezing is real, and only one level deep => ./programs/objects.test.ts:182:11
FAILED | 9 passed | 1 failed (3ms)
error: Test failed
The push did not throw; the assertion failed because nothing did. Freezing is real, the first assertThrows proved it, and it stops at the first level: the object's own entries are locked, and the array one of them points at was never frozen. A frozen object with an array in it is a promise about the entries, not about what they point at, which is the values and references page's distinction one more time. Correct the test to assert what actually happens:
Deno.test("freezing is real, and only one level deep", () => {
const frozen = Object.freeze({ id: "a", values: ["x"] });
assertThrows(
() => {
(frozen as { id: string }).id = "b";
},
TypeError,
"Cannot assign to read only property 'id' of object '#<Object>'",
);
frozen.values.push("y");
assertEquals(frozen.values, ["x", "y"]);
});
freezing is real, and only one level deep ... ok (0ms)
The write throws because a module is always strict code, and the cast is again what it takes to get past the checker, which types a frozen object's properties as readonly. There are two weaker levels, preventExtensions and seal, and the same shallowness applies to all three. Deep freezing is something you write yourself.
excess properties are refused only in a literal
Deno.test("excess properties are refused only in a literal", () => {
type PointLike = { x: number };
// @ts-expect-error: Object literal may only specify known properties, and 'y' does not exist in type 'PointLike'.
const fromLiteral: PointLike = { x: 1, y: 2 };
assertStrictEquals(fromLiteral.x, 1);
const loose = { x: 1, y: 2 };
const fromVariable: PointLike = loose;
assertStrictEquals((fromVariable as typeof loose).y, 2);
});
excess properties are refused only in a literal ... ok (0ms)
TS2353 on the first, silence on the second, and the difference confuses everyone once. Structurally, { x, y } is a perfectly good PointLike, since membership is decided by shape, the rule from the what a type is page's membership is decided by shape, so the variable assignment is sound and correct to allow. The literal gets extra scrutiny only because a property written inline that nothing wants is almost always a typo or a stale field. So the check is a helpful heuristic rather than part of the type system's logic, and knowing which it is tells you why routing through a variable makes it go away.
optional still accepts an explicit undefined
Deno.test("optional still accepts an explicit undefined", () => {
type Options = { retries?: number };
const absent: Options = {};
const explicit: Options = { retries: undefined };
assertFalse("retries" in absent);
assert("retries" in explicit);
assertStrictEquals(explicit.retries, undefined);
});
optional still accepts an explicit undefined ... ok (0ms)
retries?: number means the property may be missing, and by default it also permits the property to be present holding undefined. Those are different states, only in can tell them apart, and the reading-side version of this point is the objects as dictionaries page's delete removes the entry; undefined does not.
exactOptionalPropertyTypes is the option that separates the two states. Add it to deno.json under compilerOptions and re-check:
Check programs/objects.test.ts
TS2375 [ERROR]: Type '{ retries: undefined; }' is not assignable to type 'Options' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
Types of property 'retries' are incompatible.
Type 'undefined' is not assignable to type 'number'.
const explicit: Options = { retries: undefined };
~~~~~~~~
at file:///programs/objects.test.ts:213:11
error: Type checking failed.
One error, at exactly the line that conflates the states, and unlike the flag the dictionaries page flipped, this one found nothing else to complain about in the whole file. It is off by default, in Deno as in tsc, and the file keeps it off. Turning it on in a new project is defensible and will find real bugs, because a great deal of code passes undefined explicitly to mean "leave it out"; turning it on in an old one costs time you may not have.
a getter arrives without touching call sites
Deno.test("a getter arrives without touching call sites", () => {
const before = { first: "Ada", last: "Lovelace", full: "Ada Lovelace" };
const after = {
first: "Ada",
last: "Lovelace",
get full(): string {
return `${this.first} ${this.last}`;
},
};
assertStrictEquals(before.full, after.full);
});
a getter arrives without touching call sites ... ok (0ms)
Callers read .full either way and cannot tell which they have. That is the whole argument for accessors: start with a plain property, and add the computation later without touching a single call site.
In practice
- Use an object literal for a shape that appears in one place, and give it an
interfacewhen several places share it. - Prefer spreading to mutation, putting the spread first:
{ ...current, field: next }. - Assume a spread copy is shallow. Use
structuredClonewhen depth is required, accounting for the values it cannot preserve. - Use optional chaining at boundaries; repeated chains throughout a program call for one normalization function and a written-down shape.
- Enable
exactOptionalPropertyTypesin a new project, not an established one without migration time.