When to use a class in TypeScript
The three triggers where a TypeScript class earns its place, and why records and functions are the default everywhere else.
Picture a pull request that adds class UserService with five methods, two of them static and one that loses this inside a .map callback.
The muscle memory is from Java, C#, or Python, where a service class is the obvious shape.
The reviewer’s first question is not how to fix it, but whether it needs a class at all.
Usually it does not.
Records and functions are the default; a class is a carve-out that earns its weight at exactly three triggers: a custom Error subclass, a third-party SDK adapter wrapper, and the rare stateful domain object.
Everything else is a function over a typed record.
This lesson covers that filter, the minimum surface to ship when a trigger fires, and the class features your prior language reaches for that this stack refuses.
The three triggers for a class
Section titled “The three triggers for a class”Trigger 1: custom Error subclasses.
You met this in the previous chapter on errors: class ValidationError extends Error with a literal name discriminant that catch reads to route the failure.
The runtime contract earns the class: throw expects an Error, every framework boundary catches Error, and the literal name survives across realms when instanceof doesn’t.
Trigger 2: third-party SDK adapter wrappers.
Stripe, Better Auth, Resend, and the Postgres driver each ship their surface as a class.
Wrap the vendor class in a thin one of your own (class BillingClient, class EmailSender) to hide the vendor type from business logic, centralize construction (the apiKey, the apiVersion, the defaults), and expose application-shaped methods, so callers write createCheckoutSession(input) rather than stripe.checkout.sessions.create({ ... }).
The trigger fires when you carry SDK state across calls behind a surface shaped like your application.
Trigger 3: the rare stateful domain object.
A long-lived in-memory entity with invariants that only methods can enforce, like a Cart that totals itself or a token-bucket rate limiter that decrements and refills.
Be suspicious here: most state lives in Postgres, Zustand, or React, and most “stateful objects” you reach for are really domain records the database owns.
It fires only for genuine in-memory aggregates, whose invariants would otherwise rest on discipline rather than a guarantee.
Walk the filter below to land on 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, 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 the code you write.
Why records and functions are the default
Section titled “Why records and functions are the default”Two failure modes show up at the class 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 stays silent.
You find out at runtime, when 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, Array.prototype.map invokes it with no receiver and the binding is gone.
The class fix, declaring normalize as an arrow-field, breaks inheritance and allocates a closure per instance instead of sharing one method on the prototype.
A function has no this to lose: users.map(normalizeUser) reads what it needs from its arguments.
Failure mode two: class instances don’t cross the wire.
JSON.stringify writes only an instance’s own enumerable properties, dropping methods, getters, and #private fields.
Every wire boundary serializes to records, so a class instance crossing the network arrives record-shaped, its methods, privacy, and identity gone.
Records and functions match that shape natively; classes fight it.
The minimum class surface
Section titled “The minimum class surface”When a trigger does fire, ship only these five primitives.
constructorwith one options-object parameter. The body is assignment only: no method calls, validation, or side effects. Anything more belongs in astaticfactory.readonlyon every field not mutated after construction. Default toreadonlyunless mutation is the point. It blocks reassignment at compile time; the contents can still change (areadonlyMapis still.set()-able).#private(hash-private ), not TypeScript’sprivate. TSprivateis erased at compile time, so the field stays reachable at runtime;#privateis enforced by the JavaScript runtime and survives the compile.- Arrow-field methods only when detachment matters. Default to regular methods: 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, where thethisbinding would otherwise be lost. staticfactory methods for non-trivial construction. When a class has multiple construction paths (fromJson,fromRow,fromEnv), make themstaticmethods that return instances. The constructor stays one shape, and the call site readsBillingClient.fromEnv(env).
Here are the five primitives 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 }); }}One options object instead of positional apiKey, apiVersion, defaults, and a body that is a single assignment, with no fetch and no validation. Construction work 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 guarantees on one line. readonly is compile-time: the checker refuses any later this.#stripe = .... #stripe is runtime: the engine refuses bracket access (instance['#stripe']) and reflection. The first protects the codebase, the second 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 }); }}An arrow field, not a method. The direct call await billing.createCheckoutSession(input) works fine with regular syntax; the arrow field earns its place when the method is detached, handed to a queue, a retry helper, or any utility that re-invokes it without a receiver.
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 }); }}BillingClient.fromEnv(env) reads better than a second constructor signature, for the same reason a discriminated union beats a boolean flag: 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 a build-time barrier: if any client file imports this module, the build fails. The wrapper holds a secret, the Stripe key in #stripe, that the client must never see.
One piece sits outside the surface. When the wrapper carries 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, constructed once at module load. This module-level singleton is the default. Reach for per-request instances only when the wrapper carries request-scoped state, such as a per-tenant API key or a per-request idempotency context.
What the surface refuses
Section titled “What the surface refuses”Each primitive below is one your prior language reaches for; here is the one-line reason it stays out.
- Class inheritance beyond
extends Error. Liskov-substitution traps and the brittle-base-class problem cost more than the reuse buys. For polymorphism use a discriminated union ({ kind: 'circle'; radius: number } | { kind: 'square'; side: number }); for behavior reuse, compose functions over a record. abstract class. Theabstractkeyword erases at compile time, leaving a class the compiler refuses to instantiate but the runtime can’t enforce. Use atypeand a discriminated union instead.- Mixins. Stacked
Object.assign(C.prototype, M)calls leave unreadable stack traces; they only exist to work around single inheritance, which composition solves cleanly. - Decorators. Frameworks like NestJS and TypeORM lean on them, but Next.js, Drizzle, and Better Auth never ask for one.
- Getters and setters. They read like a field but run code: a
forloop readingcart.totalCentsten thousand times runs the totaling logic ten thousand times. A method (cart.totalCents()) makes the cost visible at the call. - Class expressions (
const C = class { ... }). Anonymous classes muddy stack traces and rename badly. Writeclass C { }at module scope, exported by name.
Which of these earns a class declaration?
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 wrapper is trigger two: Stripe ships a class, and the app wraps it to centralize state behind an app-shaped surface. The read helpers are just a module of functions; the User shape with formatName is a record paired with a function over it. Neither earns a class.
#private vs TypeScript’s private
Section titled “#private vs TypeScript’s private”Of the five primitives, this is the one to get right.
TypeScript’s private is a compile-time hint that erases at runtime; #private is a guarantee the JavaScript engine enforces.
The gap matters when the field holds a secret.
A private field reads as private only while the type checker watches; at runtime anyone with the instance reaches it, and for an API key that is a leak.
class SecretHolder { private secret = 'sk_live_xxx';}
const holder = new SecretHolder();(holder as any)['secret']; // 'sk_live_xxx' — works at runtimeprivate is erased before the engine sees the code. The checker catches a static holder['secret'], but through as any, Reflect.get, or a computed key the field reads cleanly, and Object.entries and JSON.stringify expose it. The privacy is a naming convention, nothing more.
class SecretHolder { #secret = 'sk_live_xxx';}
const holder = new SecretHolder();holder['#secret']; // undefined — the field is invisible to bracket accessThe runtime enforces #secret. Bracket access returns undefined, reflection can’t reach it, and it never appears in Object.keys, Object.entries, or JSON.stringify. Reach for it whenever the field holds anything you wouldn’t want read in a debugger or across a process boundary.
class A { #secret = 'a'; read() { return this.#secret; }}
class B { #secret = 'b'; read() { return this.#secret; }}#private fields are scoped to their declaring class, so two unrelated classes can both declare #secret without colliding, each in its own per-class slot. TS private fields are plain string-keyed properties, so two that pick the same name share one slot and write through each other. #private is the only privacy the language gives you.
The stateful domain object: a Cart aggregate
Section titled “The stateful domain object: a Cart aggregate”A shopping cart is the canonical case: adding the same SKU twice merges the quantities into one line, the total derives from the lines, and the wire shape is those lines plus the total.
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() }; }}The merge is the invariant, and it lives in addLine, so every write runs through it.
Rewrite this as functions over a Map<string, CartLine> and each caller hand-threads the Map, with nothing stopping one from skipping the merge.
The class makes the invariant a guarantee instead of a convention, and #lines keeps the state unreachable from outside.
The four methods are regular methods, not arrow fields: each is called as cart.method(...), never passed as a callback, so there is no this to preserve and each method is one copy on the prototype rather than a closure per instance.
totalCents() is a method, not a getter, for the reason the surface refused getters: the call parentheses make the loop’s cost visible where a cart.totalCents field would hide it.
toJSON() is the wire seam, paying off the serialization debt from the chapter’s first lesson.
When a Cart crosses a Server Action response or an RSC payload, JSON.stringify calls toJSON() and writes the { lines, totalCents } record it returns.
The receiver reads that typed record and reconstructs a Cart only if it needs the methods.
This shape is rare: most carts persist to the database or live in a Zustand store, and the in-memory class fires only when the data never persists and its invariants are non-trivial. The same reasoning fits a token-bucket rate limiter, an in-memory cache, or a tokenizer, a handful of times in a whole codebase.
Equality, instanceof, and the cross-realm trap
Section titled “Equality, instanceof, and the cross-realm trap”Two rules from the errors chapter carry over to the non-error case.
Reference equality.
=== compares identity, not value: new Cart() === new Cart() is false even when both are empty.
When you need value equality, use a record, whose shape is its value.
Cart stays a class to enforce an invariant, not to be compared.
instanceof and realms.
instanceof walks the prototype chain in the current realm , so a value that crossed an iframe, Worker, or vm.runInContext boundary returns false even when the class name matches.
Non-error triggers rarely cross realms: SDK wrappers are built once on the server, and domain objects stay put or get reconstructed across a serialization seam.
So instanceof fits in-realm narrowing here; name discriminants stay the cross-realm cure for errors.
Watch-outs
Section titled “Watch-outs”The five most likely PR-review findings on class code:
Final reality-check
Section titled “Final reality-check”Five claims about this lesson’s decision filter — true or false?
Each claim is about when a `class` earns its place, and what its minimum form 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) from the feature’s db/queries/users.ts and you need no class. UserService is the anti-pattern this lesson refuses.JSON.stringify on a class instance preserves the instance’s #private fields.
stringify writes own enumerable properties only, so methods, getters, and #private fields all drop. The wire is records; if its shape matters, implement toJSON().extends Error is the only inheritance this stack reaches for.
extends Error: the runtime contract for throw and catch requires an Error. Everything else composes.readonly and #private mean the same thing.
readonly is compile-time immutability: the binding can’t be reassigned, but its 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; 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; a regular method loses this when passed as a callback. Default to regular methods for cheaper memory, and reach for arrow fields when detachment is in the call path.Reveal card-by-card review
The next lesson closes the chapter by replacing Date with Temporal.
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.