In production React, Next, and Hono code, a class is not how application code is organized. It is a runtime identity: a constructor, a prototype, and an object that instanceof can recognize.
This note is the object model that Core JavaScript Concepts does not cover. That note is about when code runs. This one is about what a class is, and when it earns a keep: instanceof, a shared mutable lifetime, or a client that is one object. Otherwise types and functions. Application code consumes classes more than it writes them.
1. Runtime
A TypeScript class is a constructor function, a prototype object hanging off that constructor, and the instances new creates.
class Counter {
count = 0
inc(): number {
this.count += 1
return this.count
}
}
const a = new Counter()
const b = new Counter()
a.inc() // 1
b.inc() // 1 — separate instance fields
a.inc === b.inc // true — one shared method on Counter.prototype
a.inc === Counter.prototype.inc // truenew Counter() → instance -([[Prototype]])→ Counter.prototype -([[Prototype]])→ Object.prototype- Methods declared in the class body are installed on the prototype. Fields assigned in the constructor, or as instance fields, live on each instance.
staticmembers live on the constructor. newcreates an ordinary object, sets[[Prototype]]toClass.prototype, runs the constructor withthisbound to the new object, and returns it (unless the constructor returns a different object).- Calling a class constructor without
newthrows. Class bodies are always strict mode. - A closure can hide the same mutable
countwithout a prototype — Core JavaScript Concepts builds a counter that way — but it does not provideinstanceof, and it does not share methods across instances.
2. Chain
extends wires two prototype chains: instances of the child find methods on Child.prototype, then Parent.prototype, then Object.prototype. The constructors are linked the same way, so Child can see Parent's static members.
class Animal {
move(): string {
return "moved"
}
}
class Bird extends Animal {
fly(): string {
return "flew"
}
}
const bird = new Bird()
bird instanceof Bird // true
bird instanceof Animal // true
bird.move() // "moved" — found on Animal.prototype
const lookalike = { move: () => "moved", fly: () => "flew" }
lookalike instanceof Bird // falseinstanceofwalks the instance chain and returns true when it finds the right.prototype. It is a runtime test, not a TypeScript one. The walk reachesObject.prototype, sobird instanceof Objectis true.super()runs the parent constructor before the child touchesthis. Forgetting it is a syntax error in a derived class.- Type-level
extends— a constraint, a ternary,infer— is TypeScript Infer, Extends, and Ternaries. This section is the prototype chain. noImplicitOverrideforces theoverridekeyword when a child method replaces a parent method. That flag is TypeScript Beyond Strict.- Failure: targeting older emit so the prototype chain is gone after compilation. Target ES2015 or later. A lookalike plain object with the same fields fails
instanceof.
3. this
this inside a prototype method is the object the method was called on, not the object where the function was defined. Extract the method, and the binding is gone.
class Button {
label = "save"
onClick(): string {
return this.label
}
onClickBound = (): string => this.label
}
const button = new Button()
button.onClick() // "save"
button.onClickBound() // "save"
const { onClick, onClickBound } = button
onClickBound() // "save" — arrow captured the instance
onClick() // TypeError: Cannot read properties of undefined- Class bodies are strict, so a lost
thisisundefined, not the global object. - The same loss happens when
button.onClickis passed toaddEventListeneror to a ReactonClickprop..bind(button), an arrow field, or a wrapper() => button.onClick()all fix it. - Arrow fields close over the instance at construction. They do not go on the prototype; each instance gets its own function.
- Failure: an arrow field when one shared prototype function was the goal. The prototype method is cheaper to share. The arrow field is cheaper to pass around.
4. Types vs Values
A class declaration creates two things: a value available to new and instanceof, and a type available in annotations. An interface or a type alias creates only the type. It is erased.
interface Clock {
now(): number
}
class SystemClock implements Clock {
now(): number {
return Date.now()
}
}
class Secret {
private compileTime = "visible after emit"
#runtime = "hidden"
reveal(): string {
return this.#runtime
}
}
const clock: Clock = new SystemClock()
clock instanceof SystemClock // true
// clock instanceof Clock // not a value; does not exist at runtime
const secret = new Secret()
secret.reveal() // "hidden"
// secret.compileTime // type error; still a property on the object at runtime
// secret.#runtime // syntax error in JavaScript tooimplementsis a compile-time check that the instance shape matches. It does not change the runtime object.- TypeScript
privateandprotectedare the same kind of check: the compiler refusesobj.secret, then erases the keyword.#fields are JavaScript and stay private after emit. - Constructor parameter properties (
constructor(private name: string)) andenumemit values.erasableSyntaxOnlyforbids them so types strip clean. That flag is TypeScript Beyond Strict. - If a check must work in a
catchblock, afterJSON.parse, or across a bundle boundary, it has to be a value:instanceof, a discriminant field (type: "error"), or a branded function. - Failure:
instanceofagainst an interface that never existed at runtime.
5. Error Subclasses
The class that shows up in application code is almost always an Error subclass. catch yields unknown. instanceof distinguishes a domain failure from a programmer error, and one domain failure from another.
class AppError extends Error {
constructor(
readonly code: string,
message: string,
options?: ErrorOptions
) {
super(message, options)
this.name = "AppError"
}
}
class NotFoundError extends AppError {
constructor(resource: string, id: string) {
super("not_found", `${resource} ${id} not found`)
this.name = "NotFoundError"
}
}
try {
throw new NotFoundError("user", "missing")
} catch (error) {
if (error instanceof NotFoundError) error.code // "not_found"
else if (!(error instanceof AppError)) throw error
}- Extra fields — a
code, an HTTP status, acause— ride on the instance. A plain object{ message, code }cannot do that test after it has been thrown. - A string union on a result type is often better when the failure is a return value. That half is TypeScript Error Handling.
- Failure: throwing a plain object. Once something is
thrown, the prototype chain is the test that survives.
6. Stateful Clients and Stores
A class earns a keep when one object must hold mutable lifetime: a token, an abort controller, a connection, an in-memory cache. Methods share that state. Callers share the object.
class ApiClient {
private token: string | null = null
constructor(private readonly baseUrl: string) {}
setToken(token: string): void {
this.token = token
}
async get<T>(path: string): Promise<T> {
const headers: HeadersInit = this.token
? { Authorization: `Bearer ${this.token}` }
: {}
const response = await fetch(`${this.baseUrl}${path}`, { headers })
if (!response.ok) throw new AppError("http", `${response.status} ${path}`)
return response.json() as Promise<T>
}
}
const api = new ApiClient("https://api.example.com")
api.setToken("secret")- This is not a React tree. The client does not render. It is the same shape as an SDK client: construct once, pass around, call methods, let fields change in place.
- A module-level closure can hold
tokentoo. A class fits when callers need to construct more than one — a second base URL, a test double, a client per tenant — or wheninstanceof ApiClientis required. - A closure fits when there will only ever be one, and the prototype is unused.
7. Classes Are Consumed More Than They Are Written
Most of the classes in a TypeScript program were not written in that program. Error, Map, Set, Date, Response, URL, EventTarget, DOM nodes, SDK clients. instanceof against those types is ordinary control flow. Writing a matching class is not.
- The host and the standard library already chose classes where identity and
instanceofmatter:error instanceof SyntaxErrorafterJSON.parse,event.currentTarget instanceof HTMLButtonElement. - Application code inherits that choice at the boundary —
catch, DOM events,fetch. - Failure: inventing a parallel hierarchy for data that will be JSON tomorrow.
8. What Not to Class
React components, API payloads, and Hono handlers do not need a runtime identity. They need a type and a function.
type User = {
id: string
name: string
}
function displayName(user: User): string {
return user.name
}
const user = JSON.parse('{"id":"1","name":"Ada"}') as User
displayName(user) // "Ada"- React class components stored state on
thisand rebound event handlers by hand. Hooks moved that state onto the Fiber. New UI in this stack is a function. The rendering model is Understanding React in Depth. - A DTO that crosses the network is a type.
JSON.parsereturns a plain object. Methods hung on aclass Userwill not be there, andinstanceof Userwill be false. - A Hono handler is a function from context to response. Nest-style controller classes belong to a different stack. Backend APIs with Hono keeps routes as functions.
- Failure: inventing a class hierarchy for data that will be JSON tomorrow.
Takeaway
A TypeScript class is a constructor plus a prototype. Instance fields live on the object. Methods live on the prototype. interface and TypeScript private are erased. # and instanceof are not.
When the question is whether to write one:
- Does this need
instanceofafter athrowor across a boundary? - Does a single object need a mutable lifetime that callers share?
- Or is this data and a function? The third case does not need a class.