Classes, narrowly
The JavaScript and TypeScript class, when it earns its place on a SaaS stack and why records and functions are the default everywhere else.
A new contributor opens a pull request. The diff adds class UserService with five methods: two are static, and one quietly loses this inside a .map callback. The PR is well-meaning. The muscle memory comes from Java, C#, or Python, where a service class is the obvious shape. The reviewer’s first thought is not “this needs tweaking.” It’s whether the code needs a class at all.
In a 2026 SaaS codebase the answer is almost always no. Records and functions are the default, and a class declaration is a carve-out that earns its weight at exactly three triggers: a custom Error subclass (familiar from the previous chapter), a third-party SDK adapter wrapper, and the rare stateful domain object. Everything else is a function over a typed record. This lesson installs that trigger filter first, then shows the minimum surface to ship when a trigger fires, and finally walks the refusal list: the primitives your prior-language muscle memory will pull you toward but that this stack doesn’t reach for.
When a class earns its weight
Section titled “When a class earns its weight”Work through the decision one question at a time. Before any syntax, before the choice of readonly versus arrow-field, check whether the code in front of you matches one of three concrete shapes. If none of them fit, the answer is a module of functions over a typed record: the same getUser(id) and User shape the course has been writing since the modules chapter.
Trigger 1: custom Error subclasses. You met this shape in the previous chapter on errors: class ValidationError extends Error with a literal name discriminant that the catch reads to route the failure. It earns a class because of the runtime contract. throw expects an Error, every framework boundary catches Error, and the literal name field is what survives across realms when instanceof doesn’t. The platform fixes the shape and you follow it. The previous chapter has the canonical ValidationError body, so there’s no need to re-derive it here.
Trigger 2: third-party SDK adapter wrappers. Stripe ships its surface as a Stripe class, and Better Auth, Resend, and the Postgres driver do the same. The application wraps the vendor class in a thin one of its own (class BillingClient, class EmailSender) for three reasons. The wrapper hides the vendor type so it doesn’t leak into business logic. It centralizes construction, gathering the one apiKey, the one apiVersion, and the one set of defaults in a single place. And it exposes application-shaped methods, so callers write createCheckoutSession(input) rather than stripe.checkout.sessions.create({ ... }). The trigger fires when you need to carry SDK state across calls behind a surface shaped like your application.
Trigger 3: the rare stateful domain object. This is a long-lived in-memory entity with invariants that only methods can enforce. The textbook example is a Cart that totals itself, or a token-bucket rate limiter that decrements and refills. Be deliberately suspicious of this trigger, because on this stack state lives in Postgres, in Zustand, or in React state. Most “stateful objects” you’ll be tempted to write are really domain records that the database owns. The trigger fires only for genuine in-memory aggregates, the ones whose invariants would otherwise become a matter of discipline instead of a guarantee.
Those three are the whole list. Walk the filter below once and it points you at the right shape.
The canonical shape lives in the previous chapter on errors: a literal name discriminant, cause for chain walking, and no methods beyond constructor.
Wrap the vendor class in your own to hide the vendor type from the rest of the app, centralize construction, and expose application-shaped methods. Ship the minimum surface laid out later in this lesson.
This is a genuine in-memory aggregate with method-enforced invariants. Verify the database isn’t a better owner before committing. The Cart example later in this lesson is the canonical shape.
The default. Define the type next to the schema and export the verb-led functions from the feature module, with no class. This covers the vast majority of code in a 2026 SaaS codebase.
The default leaf is the one to memorize, because most “should this be a class?” questions land there. The three triggers are concrete, named, and finite exceptions, and you reach for one only when the code in front of you matches it.
Why records and functions are the default
Section titled “Why records and functions are the default”Two failure modes drove the 2026 shift away from putting everything in classes. Both show up at the boundary, and both vanish when the same code is a function over a record.
Failure mode one: this binding. A method passed as a callback loses this, and the compiler doesn’t warn you. You find out at runtime when something deep in a .map reaches for this.normalize and gets undefined.
const users = await listUsers();const normalized = users.map(svc.normalize);If svc.normalize reads any field through this, the call site above silently strips the binding, because Array.prototype.map invokes the function with no receiver. The class fix is to declare normalize as an arrow-field, but that comes at a price: arrow fields break inheritance, and they allocate one closure per instance instead of sharing one method on the prototype. The function-over-record alternative has no this to lose. users.map(normalizeUser) closes over its arguments and reads what it needs from them.
Failure mode two: class instances don’t cross the wire. The previous lesson on JSON named this in passing. JSON.stringify writes only a class instance’s own enumerable properties, dropping methods, getters, and #private fields entirely. Every wire boundary the course will teach (Server Actions, Server Components, RSC payloads, route handler responses) serializes its outputs to records. A class instance crossing the network boundary arrives as a record-shaped imitation of itself, with its methods, privacy, and identity gone. Records and functions match that shape natively; classes fight it.
So the default isn’t anti-class so much as pro-record. The class triggers earn their weight only when the platform itself dictates a different shape.
The minimum class surface
Section titled “The minimum class surface”When a trigger does fire, you ship only these primitives. The aim is to keep the surface small enough to hold in working memory: five primitives, in a fixed order, every time.
constructorwith one options-object parameter. Two positional arguments at most, then an options object. The constructor body is assignment only, with no method calls, validation chains, or side effects. Anything more belongs in astaticfactory.readonlyon every field that isn’t mutated after construction. Default toreadonlyeverywhere unless mutation is the whole point, and even then, prefer the smallest mutable surface.readonlyis compile-time field immutability: the field’s contents can still mutate (areadonlyMapis still.set()-able), but the binding itself can’t be reassigned.#private(hash-private ), not TypeScript’sprivatekeyword. The two are easy to confuse and they behave very differently. TSprivateis erased at compile time, so the field stays reachable via bracket notation at runtime.#privateis enforced by the JavaScript runtime: it can’t be read by bracket access, can’t be reached by reflection, and survives the compile. A later section returns to this in detail.- Arrow-field methods only when detachment matters. Default to regular method syntax: one copy on the prototype, no per-instance allocation. Reach for the arrow-field shape (
method = (input) => { ... }) only when the method is passed as a callback, stored in aMap, or attached as an event listener, the situations where thethisbinding would otherwise be lost. staticfactory methods for non-trivial construction. When the class has multiple construction paths (fromJson,fromRow,fromEnv), make themstaticmethods that return instances. The constructor stays one shape, and the call site reads asBillingClient.fromEnv(env), notnew BillingClient(env.STRIPE_KEY, '2026-04-22.dahlia', { ... }).
Here are those five primitives, named in order, applied to the canonical Stripe-adapter example.
import 'server-only';import Stripe from 'stripe';
type CheckoutInput = { priceId: string; customerId: string; successUrl: string };type CheckoutSession = { id: string; url: string };
export class BillingClient { readonly #stripe: Stripe;
constructor(options: { apiKey: string }) { this.#stripe = new Stripe(options.apiKey, { apiVersion: '2026-04-22.dahlia' }); }
createCheckoutSession = async (input: CheckoutInput): Promise<CheckoutSession> => { const session = await this.#stripe.checkout.sessions.create({ mode: 'subscription', line_items: [{ price: input.priceId, quantity: 1 }], customer: input.customerId, success_url: input.successUrl, }); return { id: session.id, url: session.url ?? '' }; };
static fromEnv(env: { STRIPE_KEY: string }): BillingClient { return new BillingClient({ apiKey: env.STRIPE_KEY }); }}The constructor takes one options object, a single named parameter rather than the positional apiKey, apiVersion, defaults. The body is one assignment line, with no fetch and no validation. If construction needed work, it would move to a static factory.
import 'server-only';import Stripe from 'stripe';
type CheckoutInput = { priceId: string; customerId: string; successUrl: string };type CheckoutSession = { id: string; url: string };
export class BillingClient { readonly #stripe: Stripe;
constructor(options: { apiKey: string }) { this.#stripe = new Stripe(options.apiKey, { apiVersion: '2026-04-22.dahlia' }); }
createCheckoutSession = async (input: CheckoutInput): Promise<CheckoutSession> => { const session = await this.#stripe.checkout.sessions.create({ mode: 'subscription', line_items: [{ price: input.priceId, quantity: 1 }], customer: input.customerId, success_url: input.successUrl, }); return { id: session.id, url: session.url ?? '' }; };
static fromEnv(env: { STRIPE_KEY: string }): BillingClient { return new BillingClient({ apiKey: env.STRIPE_KEY }); }}Two distinct guarantees sit on the same line. readonly is compile-time: the TypeScript checker refuses any later this.#stripe = .... #stripe is runtime: the JavaScript engine refuses bracket access (instance['#stripe']) and reflection. The first protects the codebase; the second protects the running process.
import 'server-only';import Stripe from 'stripe';
type CheckoutInput = { priceId: string; customerId: string; successUrl: string };type CheckoutSession = { id: string; url: string };
export class BillingClient { readonly #stripe: Stripe;
constructor(options: { apiKey: string }) { this.#stripe = new Stripe(options.apiKey, { apiVersion: '2026-04-22.dahlia' }); }
createCheckoutSession = async (input: CheckoutInput): Promise<CheckoutSession> => { const session = await this.#stripe.checkout.sessions.create({ mode: 'subscription', line_items: [{ price: input.priceId, quantity: 1 }], customer: input.customerId, success_url: input.successUrl, }); return { id: session.id, url: session.url ?? '' }; };
static fromEnv(env: { STRIPE_KEY: string }): BillingClient { return new BillingClient({ apiKey: env.STRIPE_KEY }); }}This is declared as an arrow field, not a method. The canonical call is await billing.createCheckoutSession(input), a direct call where regular method syntax would be fine. We reach for the arrow-field shape when detachment is likely on this surface: the method handed to a queue, a retry helper, or a higher-order utility that re-invokes it without a receiver. Arrow-field methods preserve the binding by construction, at the cost of one closure per instance.
import 'server-only';import Stripe from 'stripe';
type CheckoutInput = { priceId: string; customerId: string; successUrl: string };type CheckoutSession = { id: string; url: string };
export class BillingClient { readonly #stripe: Stripe;
constructor(options: { apiKey: string }) { this.#stripe = new Stripe(options.apiKey, { apiVersion: '2026-04-22.dahlia' }); }
createCheckoutSession = async (input: CheckoutInput): Promise<CheckoutSession> => { const session = await this.#stripe.checkout.sessions.create({ mode: 'subscription', line_items: [{ price: input.priceId, quantity: 1 }], customer: input.customerId, success_url: input.successUrl, }); return { id: session.id, url: session.url ?? '' }; };
static fromEnv(env: { STRIPE_KEY: string }): BillingClient { return new BillingClient({ apiKey: env.STRIPE_KEY }); }}Construction from env reads better as BillingClient.fromEnv(env) than as a second constructor signature. The factory beats overloaded constructors for the same reason discriminated unions beat boolean flag fields: the call site says what it means.
import 'server-only';import Stripe from 'stripe';
type CheckoutInput = { priceId: string; customerId: string; successUrl: string };type CheckoutSession = { id: string; url: string };
export class BillingClient { readonly #stripe: Stripe;
constructor(options: { apiKey: string }) { this.#stripe = new Stripe(options.apiKey, { apiVersion: '2026-04-22.dahlia' }); }
createCheckoutSession = async (input: CheckoutInput): Promise<CheckoutSession> => { const session = await this.#stripe.checkout.sessions.create({ mode: 'subscription', line_items: [{ price: input.priceId, quantity: 1 }], customer: input.customerId, success_url: input.successUrl, }); return { id: session.id, url: session.url ?? '' }; };
static fromEnv(env: { STRIPE_KEY: string }): BillingClient { return new BillingClient({ apiKey: env.STRIPE_KEY }); }}import 'server-only' is the build-time barrier: if any client file imports this module, the build fails. The wrapper holds a secret, the Stripe API key in #stripe, and the client must never see it. A later chapter on Server / Client boundaries owns the full directive set.
One more piece sits outside the surface itself. When the wrapper has no per-request state, and most don’t, export a configured singleton from the module:
export const billing = BillingClient.fromEnv(env);Every importer reads the same instance, and construction happens once at module load. The module-level singleton is the default. You only need per-request instances when the wrapper carries request-scoped state, such as a per-tenant API key or a per-request idempotency context. The usual case is one instance, exported once and imported everywhere.
What the surface refuses
Section titled “What the surface refuses”The list above is what the class earns. This list is what it refuses: primitives your prior-language muscle memory will reach for, with the one-line reason each one stays out.
- Class inheritance hierarchies beyond
extends Error. Favor composition over inheritance, always. The Liskov-substitution traps and the brittle-base-class problem cost more than they buy on this stack. When you need polymorphism, reach for a discriminated union ({ kind: 'circle'; radius: number } | { kind: 'square'; side: number }); when you need behavior reuse, compose functions over a record. abstract class. This is a TypeScript-only marker with no runtime guard. Theabstractkeyword is erased at compile time, so anabstractclass is just a class that the compiler refuses to instantiate directly. Reach for atypeand a discriminated union instead.- Mixins. These stack
Object.assign(C.prototype, M)calls three layers deep and leave stack traces nobody can read. They exist to work around the single-inheritance limit, and composition solves that same problem without the tangle. - Decorators. These have been TC39 Stage 3 since TypeScript 5.0 in 2023, are spec-stable, and are widely used in NestJS and TypeORM. The 2026 SaaS stack the course teaches (Next.js + Drizzle + Better Auth) doesn’t reach for them. This isn’t a claim that decorators are bad, only that this stack doesn’t need them.
- Getters and setters. They look like field reads at the call site but execute code, and the cost is the surprise: a
forloop readingcart.totalCentsten thousand times runs the totaling logic ten thousand times. Plain methods (cart.totalCents()) make the cost visible where it happens. - Class expressions (
const C = class { ... }). Anonymous classes complicate stack traces and rename badly. Always writeclass C { }at module scope, exported by name.
The principle behind every refusal is the same: reach for the smallest surface that makes the trigger work, because that is the choice that ages well. Each refused primitive solves a real problem, just not one this SaaS stack has.
Which of these earns a class declaration on this stack?
A grouping of related read helpers (getUser, listUsers, requireUser) the feature module exports.
A wrapper around the Stripe SDK that hides the vendor type, centralizes apiKey and apiVersion, and exposes app-shaped methods.
A User data shape with fields and a formatName derived value.
The SDK adapter wrapper is the second of the three legitimate triggers — Stripe ships a class, the application wraps it to centralize state and present an application-shaped surface. The read helpers are a module of functions; the User shape with formatName is a record paired with a function over it. Neither earns a class.
#private and the runtime privacy story
Section titled “#private and the runtime privacy story”Of the five primitives above, the one most likely to trip you up is the choice between TypeScript’s private keyword and the JavaScript-runtime #private syntax. They look alike, but they do different things. The TypeScript form is a compile-time hint that erases at runtime. The #private form is a runtime guarantee enforced by the JavaScript engine.
The difference matters because the failure mode is structural, not stylistic. A field marked private in TypeScript appears private when the rest of the codebase tries to read it, because the type checker refuses. But the same field is reachable at runtime by anyone holding the instance, through bracket access or Reflect.get. If the field holds a secret such as an API key or a session token, that’s a bug.
class SecretHolder { private secret = 'sk_live_xxx';}
const holder = new SecretHolder();(holder as any)['secret']; // 'sk_live_xxx' — works at runtimeTypeScript’s private is a compile-time hint, erased before the engine sees the code. A static holder['secret'] is still caught by the type checker, but the moment the access goes through as any, Reflect.get, or a computed key the checker can’t resolve, the field reads cleanly. Anyone with the instance can reach it that way, reflection and Object.entries see it, and JSON.stringify writes it. The privacy is a naming convention enforced by the compiler, nothing more.
class SecretHolder { #secret = 'sk_live_xxx';}
const holder = new SecretHolder();holder['#secret']; // undefined — the field is invisible to bracket accessThe #secret syntax is enforced by the runtime. Bracket access returns undefined, and the field doesn’t appear in Object.keys, Object.entries, or JSON.stringify. Even reflection can’t reach it, so privacy survives the compile. This is the form to reach for when the field holds anything you wouldn’t want to read in a debugger across a process boundary.
class A { #secret = 'a'; read() { return this.#secret; }}
class B { #secret = 'b'; read() { return this.#secret; }}Hash-private fields are scoped to their declaring class. Two unrelated classes can both declare #secret and never collide, because the names live in separate per-class slots rather than a shared string namespace on the instance. TS private fields, by contrast, are just regular string-keyed properties at runtime, so if two classes (or a subclass and its parent) both pick the same name, they share the slot and write through each other. #private is the only privacy the language actually gives you.
Putting it together: the stateful domain object
Section titled “Putting it together: the stateful domain object”Trigger three is the rarest of the three. Most “stateful domain objects” you’ll be tempted to write are records that the database, React state, or a Zustand store should own. The case that genuinely earns a class is an in-memory aggregate that enforces an invariant through its methods, the kind of object where refactoring it to a Map plus a few functions would turn a guarantee into a discipline.
A shopping cart is the textbook example. Adding the same SKU twice merges the quantities into one line instead of producing two. The total is derived from the lines, and the wire shape is the lines and the total packaged for serialization. Read the whole thing once, then we’ll walk through what makes it earn the trigger.
type CartLine = { sku: string; quantity: number; unitPriceCents: number };
export class Cart { readonly #lines = new Map<string, CartLine>();
addLine(line: CartLine): void { const existing = this.#lines.get(line.sku); const merged = existing ? { ...existing, quantity: existing.quantity + line.quantity } : line; this.#lines.set(line.sku, merged); }
removeLine(sku: string): void { this.#lines.delete(sku); }
totalCents(): number { let total = 0; for (const line of this.#lines.values()) { total += line.quantity * line.unitPriceCents; } return total; }
toJSON(): { lines: CartLine[]; totalCents: number } { return { lines: [...this.#lines.values()], totalCents: this.totalCents() }; }}Three things are worth noticing. First, all four methods (addLine, removeLine, totalCents, toJSON) are regular methods, not arrow fields. They are always called as cart.method(...), never passed as callbacks and never stored in a Map, so there’s no this binding to preserve. Regular methods are one prototype copy with no per-instance closure: cheaper memory, same correctness.
Second, totalCents() is a method, not a getter. The lesson refused getters one section ago, and the same reasoning holds here: a getter would let cart.totalCents read like a plain field while quietly running a loop underneath. Writing it as totalCents() makes that cost visible at the call site.
Third, toJSON() is the wire seam. When this Cart crosses an RSC payload or a Server Action response, the platform’s JSON.stringify calls toJSON() and writes the record it returns, { lines, totalCents }, not the class instance. The #lines Map disappears (it would serialize as {} otherwise), and so do the methods and the privacy. On the other side, the receiver reads a typed record, and the Cart is reconstructed only if the receiver needs the methods.
This class earns its trigger for three concrete reasons. The #lines Map is private state that nothing outside the class can read or write. The invariant, that quantities for the same SKU are summed rather than duplicated, lives in addLine’s method body rather than in the data. The Map’s key uniqueness is what makes one slot per SKU possible, but the actual merging of an incoming line into an existing one is work the method does, and every write path runs through that method. The wire shape, finally, is the toJSON() record: explicit and intentional. You could refactor this to a module of functions over a Map<string, CartLine>, but then every caller hand-threads the Map, and nothing stops one of them from skipping the merge. The class keeps that invariant guaranteed rather than merely conventional.
Keep in mind how rare this is. Most “carts” in 2026 SaaS code live in the database or in a Zustand store. This in-memory shape fires only when the cart never persists and its invariants are non-trivial. The same reasoning applies to a token-bucket rate limiter, an in-memory cache wrapper, or a parser’s stateful tokenizer, and across a whole SaaS codebase you might reach for it three or four times in total.
Equality, instanceof, and the cross-realm trap
Section titled “Equality, instanceof, and the cross-realm trap”Two more rules close out the surface. Both are pivots from the previous chapter on errors, restated for the non-error case.
Reference equality. The === operator on two class instances compares identity, not value. Two Cart instances built from the same data are not equal: new Cart() === new Cart() is false even when both are empty. When you find yourself needing value equality, the cure is to use a record instead of a class, because a record’s shape is its value and deep equality on records is well-defined. The Cart earns a class anyway, because what it offers is an enforced invariant rather than a comparable value.
instanceof and realms. Recall from the previous chapter on errors that instanceof walks the prototype chain in the current realm . Values that crossed an iframe, a Worker, or a vm.runInContext boundary carry the other realm’s prototype, so instanceof returns false even when the class name matches. For Error subclasses, the cure was the literal name discriminant, because a string comparison survives the realm crossing.
For the two non-error class triggers, the realm trap rarely comes up. SDK adapter wrappers live on the server, are constructed once, and never cross a realm. Stateful domain objects either stay in one realm or cross a serialization seam where they’re reconstructed from scratch on the other side. Inside one realm, instanceof is fine: value instanceof BillingClient narrows correctly because the prototype chain is intact. So the rule is to use name discriminants for errors that cross boundaries, and instanceof for in-realm narrowing on the other triggers.
Watch-outs
Section titled “Watch-outs”Here are the five most likely PR-review findings on class code.
Final reality-check
Section titled “Final reality-check”Here is a short round to lock in the decision filter. Read each of the five statements and mark it true or false.
Each claim is about when a `class` declaration earns its weight on this stack — and what its minimum surface looks like. Mark each statement True or False.
A UserService class with five static methods is the right shape for a feature’s read helpers on this stack.
getUser(id) and listUsers(filter) exported from the feature’s db/queries/users.ts cover the case without a class. UserService is the canonical anti-pattern this lesson refuses.JSON.stringify on a class instance preserves the instance’s #private fields.
stringify writes own enumerable properties only — methods, getters, and #private fields are all dropped. The wire is records. If the wire shape matters, implement toJSON() explicitly.extends Error is the only inheritance the 2026 SaaS stack reaches for.
extends Error, because the runtime contract for throw and catch requires an Error. Everything else composes.readonly and #private mean the same thing.
readonly is compile-time field immutability — the binding can’t be reassigned, the field’s contents can still mutate. #private is runtime-enforced visibility — the field can’t be read or written from outside the class. They solve different problems and you reach for both.Arrow-field methods (handle = () => { ... }) are the right reach when the method is passed as a callback.
this at construction, so the method survives detachment. Regular methods lose this when passed as callbacks. Default to regular methods (cheaper memory); arrow-field when detachment is in the call path.Reveal card-by-card review
The next lesson closes the chapter with the Date to Temporal pivot: the third value-shape discipline the chapter installs, and the one that retires the JavaScript standard-library date type from this codebase entirely.
External resources
Section titled “External resources”The source of `#private` semantics. The README's motivation section is the clearest one-page explanation of why hash-private exists and what it guarantees that TypeScript's `private` doesn't.
The reference for the `#field` syntax — declaration, access rules, static private elements, the `in` operator brand check, and the exact runtime errors thrown on bracket access.
Official reference for the TS-side primitives this lesson reaches for: `readonly`, parameter properties, `static`, and the soft-vs-hard private distinction at the language level.
A friendly walkthrough of the `#field` syntax against the older underscore-convention 'protected' pattern, with runnable examples showing where each enforcement boundary lives.