Skip to content

9.2.1 — Classes, Objects and the Object Lifecycle

A food delivery company needs to represent an order. Here is the whole thing, written the way most people write it on day one:

typescript
const order = {
  id: "ord_8814",
  items: [{ sku: "burger", qty: 2, priceCents: 45000 }],
  status: "placed",
};

That works, and for a script that runs once it is the right answer. It stops working when three things become true at the same time: there are thousands of these, several parts of the program create them, and there are rules about what a valid one looks like. Then you need something that can guarantee that every order in the system has an id, has at least one item, and has a status that is one of five allowed words. An object literal cannot promise you any of that. A class can.

This page is about what a class and an object actually are — not the textbook sentence about blueprints, but what exists in memory, what happens in what order when you write new, and how long the thing lives.

1. Class and object, stated properly

A class is a description: what fields an object of this kind has, what operations it supports, and what has to be true before one is allowed to exist. It is code you write once.

An object is one actual thing built from that description, sitting in memory, holding its own values. It is created at runtime, and there can be a million of them.

The blueprint-and-house analogy is fine as far as it goes, but it hides the two properties that matter in design, so here they are directly.

Every object has an identity that is separate from its contents. Two orders can hold exactly the same values and still be two different orders. If a customer places the same order twice, you have two rows in the database and you must ship twice. This is why === in JavaScript compares references rather than contents:

typescript
const a = { id: "ord_1" };
const b = { id: "ord_1" };
console.log(a === b);        // → false   different objects, same contents
console.log(a === a);        // → true    same object

Every object bundles state with the operations allowed on that state. That bundling is the entire point, and section 9.2.2 shows what it buys you. Here it is enough to notice that the object literal above has state and no operations, so nothing about it can be guaranteed.

Here is the same order as a class:

typescript
class Order {
  readonly id: OrderId;                                    // (1)
  private items: OrderLine[];                              // (2)
  private status: OrderStatus = "placed";                  // (3)

  constructor(id: OrderId, items: OrderLine[]) {           // (4)
    if (items.length === 0) {
      throw new EmptyOrderError(id);                       // (5)
    }
    this.id = id;
    this.items = [...items];                               // (6)
  }

  get total(): Money {                                     // (7)
    return this.items.reduce((sum, l) => sum.plus(l.lineTotal), Money.zero());
  }
}

Line (1) declares a field with readonly, which means TypeScript will reject any assignment to order.id after the constructor finishes. The id of an order is not a thing that changes, so saying so in the type stops a whole category of bug at compile time rather than at three in the morning.

Line (2) marks items as private, which means only code inside this class can read or write it. That is what makes the guarantee in line (5) hold. If any file could do order.items = [], the check would be decoration.

Line (3) gives status a default value at the point of declaration. Field initializers like this run for every new object, in declaration order, before the constructor body.

Line (4) is the constructor, the function that runs once when an object is created. Its real job is not "assign the fields." Its job is to refuse to produce an invalid object.

Line (5) does exactly that. An order with no lines is meaningless in this business, so rather than creating one and hoping somebody checks later, the constructor throws. From this line onward, anywhere in the entire program, if you are holding an Order you know it has at least one line. You never have to check again. That is worth more than it looks, because the alternative is a defensive if at every one of a hundred call sites, and one of them will be missing.

Line (6) copies the array with the spread syntax instead of storing the caller's array directly. If you stored the caller's array, the caller would still be holding a reference to it and could keep pushing items into your order after construction, right past your validation. This is the aliasing problem: two names pointing at one piece of memory. Copying at the boundary cuts the second name off.

Line (7) is a getter, a method that is called like a field. order.total runs the function and hands back the result. Notice that total is not stored anywhere. It is computed from the items every time it is asked for, which means it can never disagree with them. A stored total field is a second copy of the same fact, and two copies of one fact eventually differ. That is the whole class of bug that "derive it, do not store it" removes.

2. What actually happens when you write new

new Order(id, lines) looks like one operation. It is five, and knowing the order they run in explains several confusing errors.

1allocate empty object{ }2link to the prototypemethods now reachable3run field initializersstatus = "placed"4run the constructor bodyvalidate, then assign5hand back the referenceconst order = ...if step 4 throws, no reference is ever handed out — the half-built object is unreachable and gets collected
The five steps behind a single new. Steps 3 and 4 are where most surprises live, because field initializers all run before any line of your constructor body does.

Read the steps in order.

Step 1 allocates a fresh empty object on the heap. The heap is the region of memory where objects live for as long as something can reach them, as opposed to the stack, where function call frames live and disappear on return (3.4).

Step 2 links that object to its class's prototype, which is the object that holds the methods. This is why methods are not copied into every instance. A million orders share one copy of total. The lookup mechanism is the prototype chain from 3.6.4, and it is also the mechanism that makes polymorphism work in section 9.2.5.

Step 3 runs the field initializers, top to bottom, in the order you declared them. status becomes "placed" here, before your constructor body has executed a single line.

Step 4 runs the constructor body, which is where validation and assignment happen.

Step 5 hands the reference back to whoever called new.

Two practical consequences fall straight out of this ordering.

Consequence one: a field initializer cannot use a constructor parameter, because it runs first. People hit this and find the error message confusing, but the ordering explains it completely.

typescript
class Cart {
  private subtotal = this.lines.length;    // runs at step 3
  constructor(private lines: OrderLine[]) {}  // parameter assigned at step 4
}
// → TypeError: Cannot read properties of undefined (reading 'length')

Consequence two: if the constructor throws, no object escapes. Nobody ever gets a reference to the half-built thing, so nothing can use it, and the garbage collector reclaims it. This is what makes constructor validation trustworthy. An object either exists and is valid, or it does not exist at all. There is no third state, and designing so that there is no third state is most of what makes a system easy to reason about.

TypeScript also gives you a shorthand that collapses declaration and assignment. It is worth knowing because you will read it constantly:

typescript
class Order {
  constructor(
    readonly id: OrderId,                  // (1) parameter properties
    private items: OrderLine[],
  ) {
    if (items.length === 0) throw new EmptyOrderError(id);   // (2)
  }
}

Line (1) puts an access modifier (readonly, private, public, protected) directly on a constructor parameter. TypeScript then declares the field and assigns it for you. Line (2) still runs after those assignments, so validation still works. The catch is that a parameter property gives up the array copy from earlier, so if callers can hold onto the array you passed, copy it explicitly in the body instead.

3. Static members: things that belong to the class, not to any object

Sometimes a piece of data or behaviour belongs to the kind of thing rather than to any one of them.

typescript
class Order {
  static readonly MAX_LINES = 100;             // (1) one copy, on the class

  private constructor(readonly id: OrderId, private lines: OrderLine[]) {}   // (2)

  static place(lines: OrderLine[]): Order {    // (3) a named way to create one
    if (lines.length === 0) throw new EmptyOrderError();
    if (lines.length > Order.MAX_LINES) throw new TooManyLinesError();
    return new Order(OrderId.generate(), lines);
  }

  static restore(row: OrderRow): Order {       // (4) a second named way
    return new Order(row.id, row.lines);       // skips the placement rules on purpose
  }
}

Line (1) is a static field, which lives on the class itself rather than on each object. There is exactly one MAX_LINES no matter how many orders exist. Reading it is Order.MAX_LINES, not order.MAX_LINES.

Line (2) makes the constructor private, so no code outside this class can call new Order(...). That closes the door.

Lines (3) and (4) open two clearly labelled doors instead. Order.place(lines) is what a customer action does, and it enforces the business rules for placing an order. Order.restore(row) is what the database layer does when rebuilding an order that was already validated years ago, and it deliberately skips those rules, because re-validating history is how you make old data un-loadable when a rule changes.

That pattern has a name, the static factory method, and the reason to reach for it is visible right here: a constructor can have only one name, so it cannot express that "placing" and "restoring" are two different operations with two different rule sets. Named static methods can. Chapter 9.4.2 develops this into the Factory Method pattern, where the choice of which class to build also becomes flexible.

One warning that costs teams real money: static mutable state is global state. A static cache = new Map() on a class is shared by every user, every request and every test in the process. It is the global coupling from the first rung of 9.1's ladder, wearing a class's clothes. Static constants and static factories are fine. Static variables are the thing to argue about in review.

4. Entities and value objects: two kinds of object, two sets of rules

This distinction comes from Domain-Driven Design, and it is the single most practical modelling idea in this chapter, because it tells you which objects need identity and which need to be immutable.

An entity is something whose identity survives changes to its data. Order ord_8814 is the same order after you add a line, change the address, and mark it delivered. Two entities are equal when their ids are equal, and never because their fields happen to match.

A value object is something defined entirely by its contents. Five hundred rupees is five hundred rupees. There is no "which five hundred". Two value objects are equal when their contents are equal, and they should be immutable, meaning that once created they never change.

Here is why immutability matters, in a bug you have probably seen:

typescript
// ❌ mutable value object
class Money {
  constructor(public cents: number) {}
  add(other: Money): Money { this.cents += other.cents; return this; }   
}

const basePrice = new Money(50000);
const withTax = basePrice.add(new Money(9000));
console.log(basePrice.cents);   // → 59000   the base price changed!

basePrice and withTax are the same object, because add mutated the receiver and returned it. Every place still holding basePrice now sees the taxed value. Nothing threw, nothing logged, and the invoice is wrong.

The immutable version cannot fail this way:

typescript
class Money {
  private constructor(
    private readonly cents: number,                 // (1)
    private readonly currency: Currency,
  ) {}

  static of(cents: number, currency: Currency): Money {
    if (!Number.isInteger(cents)) throw new Error("Money takes whole minor units");  // (2)
    return new Money(cents, currency);
  }

  plus(other: Money): Money {                       // (3)
    if (other.currency !== this.currency) throw new CurrencyMismatchError();  // (4)
    return Money.of(this.cents + other.cents, this.currency);                 // (5)
  }

  equals(other: Money): boolean {                   // (6)
    return this.cents === other.cents && this.currency === other.currency;
  }
}

Line (1) makes both fields readonly and private, so after construction nothing can change them, inside the class or out.

Line (2) validates once, at the only place an object can be born. Every Money that exists anywhere in the program is therefore a whole number of minor units. Storing money as whole cents rather than as a decimal is not a style choice — it is because binary floating point cannot represent 0.1 exactly, which is the mechanism explained in 1.4, and the failure shows up as invoices that are off by one cent and never balance.

Line (3) returns a new Money rather than changing this one. The name is plus rather than add for a reason: plus sounds like it produces a result, while add sounds like it modifies something. Names carry that expectation and readers rely on it (9.1 section 4).

Line (4) catches adding dollars to rupees. In a plain-number design that addition silently succeeds and produces a wrong number. Here it is impossible.

Line (5) builds the result through the same validated door, so results obey the same rules as inputs.

Line (6) provides value equality, because === compares references and will report that two separate five-hundred-rupee objects are different. Any object you intend to compare by contents needs an explicit equals. There is no operator overloading in JavaScript or TypeScript, so this is a method you call.

The payoff of immutability is that an immutable object can be shared freely by any number of holders with zero coordination. Nobody can change it underneath anybody else, so a whole category of bug — the one where a value changes because of something happening in a completely different part of the program — cannot occur. This is the same argument 3.5 makes for functional programming, arriving here from the object-oriented side.

The rule of thumb: if you would put it in a database row with its own id, it is an entity. If it is a measurement, an amount, a range, a code, or a name, it is a value object, and it should be immutable.

5. The lifecycle: birth, use, and death

An object's life has three phases, and the third one is where most languages differ.

Birth happens at new, and everything in section 2 applies. The design rule is that an object should be fully usable the moment its constructor returns. Any design where you must call init() before you may call anything else is the temporal coupling from 9.1 section 2, and it will eventually be violated by somebody who did not know the rule. If construction needs asynchronous work, do not put it in the constructor, since constructors cannot await. Use a static async factory:

typescript
class OrderRepository {
  private constructor(private readonly db: DbConnection) {}

  static async connect(url: string): Promise<OrderRepository> {   // (1)
    const db = await DbConnection.open(url);                      // (2)
    return new OrderRepository(db);                               // (3)
  }
}

Line (1) is a static method that returns a promise, which is allowed. Line (2) does the waiting outside the constructor. Line (3) builds the object only once everything it needs is ready. Callers write const repo = await OrderRepository.connect(url) and get an object that is ready to use with no second step to forget.

Life is the period where the object is reachable, meaning some variable, field or array still points at it.

Death happens when nothing can reach it any more. In JavaScript, Python, Java, Go and C# the garbage collector finds unreachable objects and frees their memory automatically, and you do not write any code for this (3.4 covers how it works). In C++ and Rust the rules are different and deterministic.

Two things about death catch people out.

Garbage collection frees memory, not resources. An object holding an open file handle, a database connection, a socket or a timer holds something the operating system is tracking, and the collector does not know or care about it. The operating system will happily run out of file descriptors while your process still has plenty of free memory. So anything owning an external resource needs an explicit close, and callers need a construct that guarantees the close runs:

typescript
const handle = await FileHandle.open(path);
try {
  await handle.write(data);
} finally {
  await handle.close();        // (1) runs even if write() throws
}

Line (1) is in a finally block, which runs whether the try block completed normally or threw. Without it, one thrown error leaks a descriptor, and a leak that happens on the error path is exactly the leak that shows up under production load and never in testing.

You cannot rely on finalizers. JavaScript has FinalizationRegistry and Java has finalize(), both of which offer to run code when an object is collected. They are not usable as a cleanup mechanism, because there is no guarantee they ever run, and no guarantee about when. 3.6.11 covers what they are actually for. Explicit close, always.

The most common way objects fail to die is a reference you forgot about. An event listener that was added and never removed, a cache with no eviction, a module-level array that only ever gets pushed to. In each case the object is unreachable from your point of view and perfectly reachable from the collector's, so it stays. Memory grows slowly, the service restarts every few days, and somebody calls it "a memory leak in Node" when it is a reference in your code.

6. Object literals or classes? The honest answer

Not everything needs to be a class, and TypeScript makes the alternative genuinely good.

Use a plain type and functions when there are no rules to enforce. A configuration object read once at startup, a DTO carrying JSON from an HTTP request to a handler, a row of query results. These are data, they have no invariants, and wrapping them in classes adds ceremony that buys nothing:

typescript
type CreateOrderRequest = {          // just a shape — no rules, no behaviour
  customerId: string;
  lines: Array<{ sku: string; qty: number }>;
};

Use a class when there are rules that must hold, or when several implementations must be interchangeable. Money needs a class because "whole minor units, one currency" is a rule. Order needs a class because "at least one line" is a rule. A PaymentMethod needs to be a class or an interface because there are several and callers must not care which one they have.

There is one performance detail worth knowing, though it should almost never drive a design decision. V8 gives objects with the same shape a shared internal description called a hidden class, and code that always sees the same shape at a call site runs much faster (3.6.9). Classes produce consistent shapes by construction, whereas object literals built by adding fields in varying orders do not. Measure before you care, but know that "classes are slow in JS" is folklore that has been false for over a decade.

What the next page adds. You now have objects that hold state and rules. Chapter 9.2.2 is about the boundary around that state: which parts you show, which you hide, and why a class full of getters and setters has hidden nothing at all.

Recall

  • A class describes; an object is one instance with its own state, its own identity, and the operations allowed on it. Two objects with identical contents are still two objects, which is why === compares references.
  • new runs five steps: allocate, link to the prototype, run field initializers in declaration order, run the constructor body, return the reference. Field initializers run before the constructor body, and a throwing constructor hands out nothing, which is what makes constructor validation trustworthy.
  • Constructors exist to refuse invalid objects. Copy incoming arrays and objects at the boundary to cut aliasing. Derive values with getters instead of storing a second copy of the same fact.
  • static members belong to the class: constants and named static factory methods are good, static mutable state is global state in disguise.
  • Entities have identity over time and compare by id. Value objects are defined by their contents, compare by equals, and should be immutable so that sharing them is always safe.
  • Objects must be usable the moment the constructor returns; use a static async factory when setup needs await. Garbage collection frees memory but not file handles or sockets, so resources need explicit close() in a finally, and finalizers are never a cleanup plan.

Self-test: Why can a field initializer not read a constructor parameter? What guarantee do you gain when a constructor throws instead of returning a flawed object? Give one entity and one value object from a domain you know, and say which one needs equals. Why does storing an order's total as a field invite bugs? Name the leak that garbage collection cannot fix.

Quiz Bank

FoundationalWhat is the difference between an entity and a value object, and what changes in the code because of it?

An entity has an identity that survives changes to its data. Order ord_8814 is still that order after you add a line, change the delivery address and mark it delivered. A value object is defined entirely by its contents: five hundred rupees is five hundred rupees, and asking which five hundred is meaningless.

Three concrete things change in the code. Equality: entities compare by id and only by id, so two objects loaded from the same database row in two places are the same entity even if one is stale; value objects compare by contents, and since === compares references you must write an equals method for them. Mutability: entities change over time by design, and that is what having an identity is for; value objects should be immutable, so operations return new instances (price.plus(tax)) rather than modifying the receiver. Validation: a value object validates once in its constructor or static factory, and after that every instance in the program is lawful forever, which means no function that receives one ever has to check it again.

The practical test: if it would get its own row and its own id in a database, it is an entity. If it is an amount, a range, a code, a name or a measurement, it is a value object.

AppliedA constructor takes an array of order lines and stores it directly. What is the bug, and what are the two fixes?

The bug is aliasing. The caller still holds a reference to the same array, so the array now has two owners, and the object's validation only ran once at construction time:

typescript
const lines = [makeLine("burger", 1)];
const order = new Order(id, lines);   // validated: not empty ✓
lines.length = 0;                     // the caller empties it afterwards
order.total;                          // → zero. The invariant is now false.

Nothing threw. The order still claims to be valid because the check happened in the past, and the object's state changed behind its back through a door it did not know existed. This is the same hidden-channel problem from 9.1 section 2, at the scale of one object.

Fix one, copy at the boundary: this.items = [...items] in the constructor. The object now owns its own array and nobody else can reach it. This is a shallow copy, so if the elements are mutable objects you have only moved the problem one level down, which is one more reason to make the elements value objects.

Fix two, make the input immutable: accept readonly OrderLine[], which stops TypeScript from allowing push or length assignment through that reference. This is compile-time only and disappears at runtime, so it protects you from your own team's mistakes but not from JavaScript callers or from a caller who kept the original mutable reference. Use both when the object matters: copy at the boundary and type the parameter as readonly.

InterviewWhy should a constructor throw rather than build an object that is not yet valid?

Because it removes a state from your program. If a constructor can return a half-valid object, then every function that receives one has to consider two cases: valid and not-yet-valid. With a hundred call sites that is a hundred checks, and the interesting bugs come from the two or three that are missing.

When the constructor throws, no reference to the half-built object is ever handed out. It is unreachable the instant the exception propagates and the collector takes it. So the type Order genuinely means "a valid order", and that meaning is enforced by the language rather than by a comment or a convention.

This is the same idea as making illegal states unrepresentable at the type level (3.7.2), except the check happens at runtime and covers values the type system cannot see, like "this string is a well-formed email address" or "these two dates are in order".

The bound worth stating: this works for construction-time rules. Rules that involve other objects or the database — "this customer has no unpaid invoices" — do not belong in a constructor, because a constructor cannot do input and output and should not perform queries. Those checks belong in the use case that creates the object, and the constructor still enforces everything it can see on its own.

StaffA Node service grows its memory steadily over four days and then gets restarted by the orchestrator. Heap snapshots show tens of thousands of live Order objects that the team believes were finished with hours ago. How do you reason about this?

The framing to correct first: the collector is not failing. An object is collected exactly when nothing can reach it, so tens of thousands of live orders means something in the process is still holding references to them. The task is to find the holder, not to tune the collector.

The usual holders, in the order worth checking. A cache with no eviction and no size limit, which is a Map that only ever grows. Event listeners registered per request and never removed, where each closure captures the request's objects, so every listener pins an order graph (3.6.2 explains why the whole scope is captured, not just the variable you named). A module-level array used for metrics or debugging that is pushed to and never drained. Timers or intervals whose callbacks capture request state. Promises that never settle, since a pending promise keeps its continuation and everything that continuation closes over alive.

How to find it rather than guess. Take two heap snapshots minutes apart under steady load and compare, then use the retainer path on one surviving Order — the tool tells you exactly which chain of references keeps it alive, and that chain names the offending module directly.

The fixes, matched to cause. Bound the cache with a maximum size and an eviction policy, because an unbounded cache is a memory leak with good intentions. Remove listeners in the same place you add them, using finally or an AbortController signal so removal survives errors. Prefer WeakMap when you want to attach data to an object without keeping the object alive, since its keys are held weakly and do not prevent collection (3.6.11). Clear timers when the work they belong to is cancelled.

Then stop it recurring, since this class of bug is invisible in testing and only appears at production duration. Add a memory metric with an alert on the growth trend rather than on the absolute value, and run a soak test that holds realistic load for hours in a pipeline. The restart every four days is the orchestrator hiding the symptom, which is useful for uptime and dangerous for diagnosis, because it converts a crash into a slow tax nobody is paged for.