Skip to content
TR

Phase 3 · Classes, enums and modules · Lesson 3.1

Intermediate

Classes

How TypeScript types JavaScript classes: fields, access modifiers vs #private, readonly, abstract classes, implements, override, polymorphic this, and why classes are still compared structurally.

25 min

Classes are plain JavaScript. What TypeScript adds is a layer of compile-time rules on top: which fields exist, who may touch them, and what a subclass must provide. Interviewers love classes because the line between "checked by TypeScript" and "enforced at runtime" is sharp here, and most people blur it.

Fields and constructors

In TypeScript you declare a field before you use it. A field can have a type, an initializer, or both.

class Counter {
  count = 0;            // type inferred as number
  label: string;        // declared, assigned in the constructor
 
  constructor(label: string) {
    this.label = label;
  }
 
  increment(): number {
    return ++this.count;
  }
}
 
const c = new Counter("clicks");
c.increment();
// @ts-expect-error -- Property 'total' does not exist on type 'Counter'.
c.total;

Assigning to this.total without declaring total is an error too. Unlike plain JavaScript, a class's shape is fixed by its declarations.

strictPropertyInitialization and the ! escape hatch

Under strict, strictPropertyInitialization requires every non-optional field to be definitely assigned by the end of the constructor. The check only looks at the constructor body itself, not at methods it calls.

class Connection {
  // @ts-expect-error -- Property 'url' has no initializer and is not definitely assigned in the constructor.
  url: string;
  retries?: number;     // optional: may be undefined, no error
  socket!: WebSocket;   // definite assignment assertion: "trust me"
 
  constructor() {
    this.setUp();
  }
 
  setUp() {
    this.url = "wss://example.com";
    this.socket = new WebSocket(this.url);
  }
}

The ! after socket is a definite assignment assertion. It silences the check and nothing else: if you are wrong, you get undefined at runtime with a type that says WebSocket. Prefer assigning in the constructor, a field initializer, or an optional ? field. Reserve ! for fields set by a framework or an init() you control.

Parameter properties

Writing a field, a constructor parameter and an assignment for every value is repetitive. A parameter property does all three: put an access modifier (public, private, protected) or readonly in front of a constructor parameter.

class User {
  constructor(
    public readonly id: string,
    private email: string,
  ) {}
 
  domain() {
    return this.email.split("@")[1];
  }
}
 
const u = new User("u1", "ada@example.com");
const id = u.id;
//    ^? const id: string

Two gotchas worth knowing:

  • Parameter properties are not plain JavaScript: TypeScript has to generate this.id = id in the constructor. That makes them incompatible with --erasableSyntaxOnly (covered in the enums lesson).
  • A field initializer that reads a parameter property can run too early. With modern class fields (target ES2022+, where useDefineForClassFields is on by default), field initializers run before the constructor body assigns parameter properties, and TypeScript catches it:
class Price {
  // @ts-expect-error -- Property 'amount' is used before its initialization.
  doubled = this.amount * 2;
  constructor(public amount: number) {}
}

public, private, protected vs #private

TypeScript has three access modifiers:

ModifierAccessible from
public (default)anywhere
protectedthe class and its subclasses
privatethe declaring class only

The catch: these are compile-time only. After emit, a private field is an ordinary property. Anyone can read it at runtime, and even TypeScript lets you through with bracket notation:

class Vault {
  private pin = "1234";
  #code = "9999"; // ECMAScript private field
 
  hasSameCode(other: Vault) {
    return this.#code === other.#code; // allowed: same class
  }
}
 
const v = new Vault();
// @ts-expect-error -- Property 'pin' is private and only accessible within class 'Vault'.
v.pin;
v["pin"];          // allowed! bracket access is an intentional escape hatch
console.log(JSON.stringify(v)); // {"pin":"1234"} — private is still a real property

#code is different. # fields are a JavaScript feature (ES2022), enforced by the runtime: they don't show up in Object.keys, JSON.stringify or bracket access, and reading one from outside the class body is a syntax error in JavaScript itself.

private#private
Enforced byTypeScript onlythe JavaScript engine
Bracket access obj["x"]allowedimpossible
Visible in JSON.stringify / Object.keysyesno
Subclass can declare the same nameno (error)yes (separate slots)
Brand checkno#x in obj

# fields also give you an ergonomic brand check: #code in obj is true only for objects created by this class's constructor.

class Token {
  #secret = crypto.randomUUID();
  static isToken(value: unknown): value is Token {
    return typeof value === "object" && value !== null && #secret in value;
  }
}

Quick check

Which line is a compile error?

class Account {
  private balance = 100;
}
const a = new Account();
a.balance;                          // Line A
a["balance"];                       // Line B
console.log(Object.keys(a));        // Line C

readonly

readonly fields can be assigned in their declaration or in the constructor, and nowhere else.

class Config {
  readonly tags: string[] = [];
  readonly env: string;
 
  constructor(env: string) {
    this.env = env; // fine: inside the constructor
  }
 
  rename() {
    // @ts-expect-error -- Cannot assign to 'env' because it is a read-only property.
    this.env = "prod";
  }
}
 
const cfg = new Config("dev");
cfg.tags.push("beta"); // allowed: readonly is shallow

readonly protects the binding, not the value. The array itself is still mutable; use readonly string[] (or ReadonlyArray<string>) to stop push. And like private, it is erased: nothing stops mutation at runtime.

Getters and setters

Accessors look like properties from the outside. A getter without a setter makes the property read-only.

class Temperature {
  #celsius = 0;
 
  get fahrenheit(): number {
    return this.#celsius * 1.8 + 32;
  }
  set fahrenheit(value: number | string) {
    this.#celsius = (Number(value) - 32) / 1.8;
  }
 
  get kelvin() {
    return this.#celsius + 273.15;
  }
}
 
const t = new Temperature();
t.fahrenheit = "212";  // setter accepts number | string
const f = t.fahrenheit;
//    ^? const f: number   (the getter type)
// @ts-expect-error -- Cannot assign to 'kelvin' because it is a read-only property.
t.kelvin = 0;

Since TypeScript 5.1 the getter and setter types can be unrelated; before that, the getter type had to be assignable to the setter type.

Static members

static members live on the class (the constructor function), not on instances.

class IdGenerator {
  static #next = 1;
  static readonly prefix = "id_";
 
  static create(): string {
    return IdGenerator.prefix + IdGenerator.#next++;
  }
 
  static {
    // static initialization block: runs once when the class is defined
    IdGenerator.#next = 100;
  }
}
 
IdGenerator.create();
// @ts-expect-error -- Property 'create' does not exist on type 'IdGenerator'. Did you mean to access the static member 'IdGenerator.create' instead?
new IdGenerator().create();

A static member cannot use the class's type parameters (class Box<T> { static empty: T } is an error), because there is one static side shared by every Box<T>.

A class is both a type and a value

class Point {} creates two things with the same name:

  • a type Point: the shape of an instance;
  • a value Point: the constructor function, whose type is written typeof Point.
class Point {
  static origin = new Point(0, 0);
  constructor(public x: number, public y: number) {}
}
 
const p: Point = new Point(1, 2);        // instance type
const Ctor: typeof Point = Point;        // constructor type, has .origin
Ctor.origin;
 
type Instance = InstanceType<typeof Point>;
//   ^? type Instance = Point
 
function make(C: new (x: number, y: number) => Point) {
  return new C(3, 4);
}
make(Point);

new (...) => T is a construct signature: "something you can call with new". That's how you write factories that accept a class.

Abstract classes

An abstract class can't be instantiated. It can declare abstract members that subclasses must implement, next to normal members they inherit.

abstract class Shape {
  abstract area(): number;
  abstract readonly name: string;
 
  describe(): string {
    return `${this.name} with area ${this.area().toFixed(2)}`;
  }
}
 
class Circle extends Shape {
  readonly name = "circle";
  constructor(private radius: number) {
    super();
  }
  area() {
    return Math.PI * this.radius ** 2;
  }
}
 
// @ts-expect-error -- Cannot create an instance of an abstract class.
new Shape();
new Circle(2).describe();

Unlike an interface, an abstract class exists at runtime and can carry shared implementation. To accept "any concrete subclass" as a value, use an abstract construct signature: abstract new () => Shape accepts both abstract and concrete classes, while new () => Shape accepts only concrete ones.

implements checks, it does not change the class

class A implements I asks TypeScript to verify that A is assignable to I. That's all. It does not copy types from I into A: method parameters are not contextually typed, and optional members are not added.

Quick check

Which lines error under strict?

interface Checkable {
  check(name: string): boolean;
  label?: string;
}
 
class NameChecker implements Checkable {
  check(s) {                    // Line A
    return s.length > 0;
  }
}
 
new NameChecker().label;        // Line B

A class can implement several interfaces (implements A, B), and it can even implement another class's type (implements Point), because a class type is just a shape.

override and noImplicitOverride

The override keyword marks a method that replaces a base-class member. TypeScript then errors if the base has no such member, which catches typos and base-class renames:

class Logger {
  log(message: string) {
    console.log(message);
  }
}
 
class TimestampLogger extends Logger {
  override log(message: string) {
    super.log(`${new Date().toISOString()} ${message}`);
  }
 
  // @ts-expect-error -- This member cannot have an 'override' modifier because it is not declared in the base class 'Logger'.
  override logg(message: string) {}
}

override alone is opt-in. Turn on noImplicitOverride in tsconfig.json and the reverse is enforced too: overriding a member without writing override becomes an error (This member must have an 'override' modifier because it overrides a member in the base class). Together they guarantee every override is intentional. noImplicitOverride is not part of strict; you enable it separately.

Polymorphic this types

Inside a class, this can be used as a type: "the type of whatever instance this is called on". It's what makes fluent APIs survive subclassing.

class QueryBuilder {
  protected parts: string[] = [];
 
  where(clause: string): this {
    this.parts.push(`WHERE ${clause}`);
    return this;
  }
}
 
class PagedQueryBuilder extends QueryBuilder {
  limit(n: number): this {
    this.parts.push(`LIMIT ${n}`);
    return this;
  }
}
 
const q = new PagedQueryBuilder().where("age > 18").limit(10);
//    ^? const q: PagedQueryBuilder

▶ Try it in the TypeScript Playground

If where returned QueryBuilder instead of this, the chain would lose the subclass and .limit would be an error. You can also use this in parameter positions (equals(other: this)) and as a type guard return (isActive(): this is ActiveUser).

Classes are compared structurally

TypeScript's type system is structural, and classes are no exception. Two classes with the same public shape are interchangeable, and an object literal can stand in for a class instance:

class Cat {
  constructor(public name: string) {}
}
class Dog {
  constructor(public name: string) {}
}
 
const pet: Cat = new Dog("Rex"); // fine: same shape
const fake: Cat = { name: "Tom" }; // fine too
console.log(fake instanceof Cat);  // false at runtime!

This is the classic trap: a value typed Cat is not guaranteed to pass instanceof Cat.

The exception: private, protected and # members make a class nominal. A type with a private member is only compatible with types whose private member comes from the same declaration.

class Meters {
  private readonly unit = "m";
  constructor(public value: number) {}
}
class Feet {
  private readonly unit = "m";
  constructor(public value: number) {}
}
 
// @ts-expect-error -- Types have separate declarations of a private property 'unit'.
const m: Meters = new Feet(3);

That's a cheap way to get branded, non-interchangeable class types.

Quick check

What does this print, and does it compile?

class Settings {
  theme = "dark";
}
const s: Settings = { theme: "light" };
console.log(s instanceof Settings);

Spot the error

This class looks fine, and compiles in plain JavaScript. Why does TypeScript reject it under strict?

class Cache {
  store: Map<string, string>;
 
  constructor() {
    this.reset();
  }
 
  reset() {
    this.store = new Map();
  }
}
Show the answer

strictPropertyInitialization only looks at the constructor body. It doesn't follow the call into reset(), so store is reported as Property 'store' has no initializer and is not definitely assigned in the constructor. The cleanest fix is an initializer (and reset just replaces it):

class Cache {
  store = new Map<string, string>();
 
  reset() {
    this.store = new Map();
  }
}

If the assignment genuinely has to happen elsewhere, store!: Map<string, string>; works, but it moves the responsibility from the compiler to you.

Recap

  • Declare fields before use; strictPropertyInitialization requires assignment in the constructor (or an initializer, ?, or !).
  • Parameter properties (constructor(private x: T)) declare and assign in one go, but aren't erasable syntax.
  • public/protected/private are compile-time only; #private is enforced at runtime.
  • readonly is shallow and compile-time only; a getter without a setter is read-only.
  • A class name is both an instance type and a constructor value (typeof C).
  • Abstract classes can't be instantiated and can mix abstract and concrete members.
  • implements only checks; it never types parameters or adds members.
  • override + noImplicitOverride make every override explicit.
  • Return this for fluent methods that work in subclasses.
  • Classes are structural, except that private/protected/# members make them nominal.

Interview cards

1 / 8