TypeScript Classes

September 14, 20269 min read
Requirements:
FunctionsObjectsGenerics

A TypeScript class is a JavaScript class with type annotations bolted on. The runtime semantics are unchanged — same prototype chain, same new, same inheritance. What TypeScript adds is a layer of compile-time bookkeeping: field types, access modifiers, abstract, implements, and a shorthand for the constructor boilerplate you would otherwise write by hand.

That framing matters, because roughly half of what a TypeScript class gives you disappears at build time. Knowing which half is which is most of what this article is about.

What a TypeScript class actually adds

Start with the version that would work in plain JavaScript, plus types:

class BankAccount {
  owner: string
  balance: number
 
  constructor(owner: string, balance: number) {
    this.owner = owner
    this.balance = balance
  }
 
  deposit(amount: number): void {
    this.balance += amount
  }
}
 
const account = new BankAccount('Ada', 100)
account.deposit(50)

Two field declarations, a typed constructor, a typed method. Strip the annotations and you have the JavaScript class you already know.

The field declarations are doing real work under strict. TypeScript's strictPropertyInitialization check insists that every declared property is assigned before anyone can read it, which is why a bare balance: number with no constructor assignment raises TS2564: Property has no initializer. There are three honest answers to it:

class HttpService {
  // ✅ a default at the declaration site
  baseUrl = 'https://api.example.com'
 
  // ✅ optional — the type admits it might be missing
  token?: string
 
  // ✅ a promise that a framework assigns it before first read
  client!: object
}

Reach for ! last. It silences the check without making the property any safer.

Parameter properties cut the boilerplate

Assigning constructor arguments to identically named fields is tedious enough that TypeScript gives it dedicated syntax. Put an access modifier on a constructor parameter and the field is declared and assigned for you:

class ConciseAccount {
  constructor(
    public owner: string,
    private balance: number,
  ) {}
 
  deposit(amount: number): void {
    this.balance += amount
  }
}

Same class as the one above, six lines shorter. This is one of the few places where TypeScript emits code rather than erasing it, and it is genuinely worth using.

The modifier is what triggers the shorthand. A bare constructor(owner: string) stays an ordinary parameter that vanishes when the constructor returns, so if you drop the public you also drop the field — a quiet edit that turns this.owner into a compile error two lines down.

private is a suggestion, #private is a lock

public, private, and protected are checked by the compiler and then thrown away. Nothing stops a JavaScript caller — or a JSON.stringify, or a debugger — from reaching a private field at runtime. JavaScript's own # fields are enforced by the engine:

class Vault {
  private secret = 'compile-time only'
  #realSecret = 'runtime too'
 
  reveal(): string {
    return `${this.secret} / ${this.#realSecret}`
  }
}
 
const vault = new Vault()
console.log(vault.reveal())

Use private when you are drawing a line for your own team, which is most of the time — it reads better and plays nicely with mocks and tests. Use # when the boundary has to survive contact with untyped callers, like a library you publish to npm.

protected sits in between: visible to subclasses, hidden from everyone else. It is only useful if you are actually building an inheritance hierarchy, and most codebases are not.

One consequence of private being compile-time only is worth remembering when you write tests. A private field is still there at runtime, so (service as any).cache reaches it and the test passes. That is occasionally the pragmatic escape hatch, and it is also a decent signal that the thing you are trying to test wants to be a separate, public unit instead.

readonly and getters

readonly stops reassignment after construction. It is shallow — a readonly array field can still be pushed to — but it catches the common mistake:

class Temperature {
  readonly celsius: number
 
  constructor(celsius: number) {
    this.celsius = celsius
  }
 
  get fahrenheit(): number {
    return this.celsius * 1.8 + 32
  }
}
 
const today = new Temperature(21)
console.log(today.fahrenheit)

A getter is the right tool when a value is derived rather than stored. Callers read today.fahrenheit as if it were a field and never have to know it was computed.

static members and typeof MyClass

static members live on the constructor, not on instances. That raises a question people get stuck on: how do you type "the class itself" rather than "an instance of it"? The answer is typeof:

class AppLogger {
  static instances = 0
 
  constructor(public name: string) {
    AppLogger.instances += 1
  }
}
 
// `AppLogger` is the instance type. `typeof AppLogger` is the constructor.
function makeLogger(ctor: typeof AppLogger, name: string): AppLogger {
  return new ctor(name)
}
 
const httpLogger = makeLogger(AppLogger, 'http')

A class declaration quietly creates two things with the same name: a type (the instance shape) and a value (the constructor). typeof is how you ask for the second one.

extends, super, and override

Inheritance works the way it does in JavaScript, with one extra rule: a subclass constructor has to call super() before it touches this. TypeScript enforces that rather than letting you discover it at runtime.

class BaseRepo {
  constructor(protected table: string) {}
 
  find(id: string): string {
    return `select * from ${this.table} where id = ${id}`
  }
}
 
class UserRepo extends BaseRepo {
  constructor() {
    super('users')
  }
 
  override find(id: string): string {
    return `${super.find(id)} and deleted_at is null`
  }
}

protected is what lets UserRepo read this.table while keeping it off the public surface, and super.find(id) calls the parent implementation rather than recursing.

The override keyword is optional but worth turning on with noImplicitOverride in your tsconfig.json. Without it, renaming a method on the base class silently orphans the subclass version: it keeps compiling, it just stops being called. With it, the compiler tells you.

Deep hierarchies age badly, though. Two levels is usually the point where a change to the base class starts breaking descendants you had forgotten about, and composition — holding an instance rather than extending it — gets you the reuse without the coupling.

abstract and implements

abstract marks a class that cannot be instantiated and may declare methods without bodies. implements checks a class against an interface without affecting what the class inherits:

interface Drawable {
  area(): number
}
 
abstract class ShapeBase implements Drawable {
  abstract area(): number
 
  describe(): string {
    return `area ${this.area().toFixed(2)}`
  }
}
 
class CircleShape extends ShapeBase {
  constructor(private radius: number) {
    super()
  }
 
  area(): number {
    return Math.PI * this.radius * this.radius
  }
}
 
const shapes: Drawable[] = [new CircleShape(2)]

implements is a checked assertion, nothing more. It does not add members, and it does not make the class assignable to anything it was not already assignable to — TypeScript is structural, so CircleShape would satisfy Drawable with or without the keyword. The value of writing it is that the error lands on the class definition instead of at some distant call site.

Generic classes

Type parameters work on classes exactly as they do on functions. If you are comfortable with generics, there is nothing new here:

class TypedStack<T> {
  private items: T[] = []
 
  push(item: T): void {
    this.items.push(item)
  }
 
  pop(): T | undefined {
    return this.items.pop()
  }
}
 
const stack = new TypedStack<string>()
stack.push('a')
const topItem = stack.pop()

The parameter is bound when you write new TypedStack<string>(), and every method is typed against it from there. Note that pop returns T | undefined — an empty stack is a real possibility and the type says so.

You can usually skip the explicit <string>. TypeScript infers the type parameter from the constructor arguments when there are any, so a class that takes an initial value in its constructor — say class Box<T> { constructor(public value: T) {} } — lets you write new Box('a') and get Box<string> for free. TypedStack above declares no constructor, so there is nothing to infer from and the explicit <string> has to be spelled out.

The this problem

Methods are not bound to their instance. Pull one off an object and pass it somewhere as a callback and this is gone, which is exactly the situation behind TS2683: 'this' implicitly has type 'any':

class ClickTracker {
  count = 0
 
  // ❌ loses `this` the moment it is passed as a callback
  increment(): void {
    this.count += 1
  }
 
  // ✅ an arrow-function property captures `this` at construction
  incrementSafely = (): void => {
    this.count += 1
  }
}
 
const tracker = new ClickTracker()
const detached = tracker.increment
// detached() throws at runtime — `this` is undefined
 
const bound = tracker.incrementSafely
bound()

The arrow-function field fixes it at the cost of one closure per instance, which is fine for a handful of objects and wasteful for thousands. The alternative is to bind at the call site with () => tracker.increment() and leave the class alone.

When to reach for a class

The honest answer in a TypeScript codebase is: less often than you think. A class earns its place when instances carry mutable state that must stay consistent, when you need many objects sharing one set of methods, or when a framework asks for one.

It is the wrong tool when you are only grouping related data. A type and a function do that with less ceremony, and they narrow better:

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number }
 
function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius * shape.radius
    case 'square':
      return shape.side * shape.side
  }
}

This is the same polymorphism the ShapeBase hierarchy gave you, minus the inheritance. Add a third variant and the compiler points at every switch that has not handled it — inheritance gives you no such warning. Data as types, behaviour as functions, is the default that fits the rest of the language.

Once you are comfortable with what a class exposes, the ClassPublicKeys challenge is a good next step: it asks you to extract a class's public property names at the type level, which is a sharp way to find out whether private really means what you assumed.

Summary

A TypeScript class is a JavaScript class plus compile-time checks. Parameter properties and readonly are cheap wins. private is a team convention while # is a runtime guarantee. typeof MyClass types the constructor, implements just checks a shape you already satisfy, and methods still lose this when detached. Reach for a class when state and behaviour genuinely belong together — and for everything else, a type and a function will serve you better.

Share this article

Become a TypeScript Pro

Track your progress through 100+ hands-on challenges. Free, sign in with GitHub.

Or start solving right away: explore all TypeScript challenges

Practice with Challenges

Put your typescript classes knowledge to the test with these related challenges.

#2828ClassPublicKeys
Hard
#4Pick
Easy
#2Get Return Type
Medium

Related Concepts

Concepts that build on or relate to typescript classes.

InterfacesTypeScript GenericsTypeScript typeofTypeScript Discriminated Unions