Skip to content
Back

TypeScript Classes and Runtime Identity

TypeScript

A class is a constructor plus a prototype. A class earns a keep when instanceof or a mutable lifetime is required; otherwise types and functions

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.


ts
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 // true

text
new 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. static members live on the constructor.
  • new creates an ordinary object, sets [[Prototype]] to Class.prototype, runs the constructor with this bound to the new object, and returns it (unless the constructor returns a different object).
  • Calling a class constructor without new throws. Class bodies are always strict mode.
  • A closure can hide the same mutable count without a prototype — Core JavaScript Concepts builds a counter that way — but it does not provide instanceof, 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.


ts
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 // false

  • instanceof walks the instance chain and returns true when it finds the right .prototype. It is a runtime test, not a TypeScript one. The walk reaches Object.prototype, so bird instanceof Object is true.
  • super() runs the parent constructor before the child touches this. 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.
  • noImplicitOverride forces the override keyword 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.


ts
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 this is undefined, not the global object.
  • The same loss happens when button.onClick is passed to addEventListener or to a React onClick prop. .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.


ts
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 too

  • implements is a compile-time check that the instance shape matches. It does not change the runtime object.
  • TypeScript private and protected are the same kind of check: the compiler refuses obj.secret, then erases the keyword. # fields are JavaScript and stay private after emit.
  • Constructor parameter properties (constructor(private name: string)) and enum emit values. erasableSyntaxOnly forbids them so types strip clean. That flag is TypeScript Beyond Strict.
  • If a check must work in a catch block, after JSON.parse, or across a bundle boundary, it has to be a value: instanceof, a discriminant field (type: "error"), or a branded function.
  • Failure: instanceof against 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.


ts
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, a cause — 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.


ts
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 token too. A class fits when callers need to construct more than one — a second base URL, a test double, a client per tenant — or when instanceof ApiClient is 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 instanceof matter: error instanceof SyntaxError after JSON.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.


ts
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 this and 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.parse returns a plain object. Methods hung on a class User will not be there, and instanceof User will 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:

  1. Does this need instanceof after a throw or across a boundary?
  2. Does a single object need a mutable lifetime that callers share?
  3. Or is this data and a function? The third case does not need a class.

Recap Q&A