Lesson 13 of 20

Classes in TypeScript

Fields, Constructors and the Initialisation Check

Classes in TypeScript are JavaScript classes with types attached. Every field must be declared with its type before it can be used, which is a change from JavaScript where you can invent properties in the constructor. Declaring them up front means the class has a readable list of its own state at the top, and it is what lets your editor autocomplete this. anywhere in the body.

Under strict, a check called strictPropertyInitialization insists that every declared field is definitely assigned: either given an initialiser on the declaration line, or assigned in the constructor. Miss one and you get an error, because the alternative is a field that is undefined despite its type promising otherwise — a lie that will crash later somewhere unrelated.

Two escape hatches exist and both should be used sparingly. Adding | undefined to the type is honest: the field really can be empty, and every reader is now told so. The definite assignment assertion, a ! after the field name, tells the compiler "trust me, something else sets this" — appropriate when a framework injects the value, and a promise you can break like any other.

Example
class Student {
  name: string;
  marks: number;
  remarks: string = "";        // initialiser on the declaration
  mentor: string | undefined;  // honestly optional

  constructor(name: string, marks: number) {
    this.name = name;
    this.marks = marks;
  }

  passed(): boolean {
    return this.marks >= 33;
  }
}

const s = new Student("Ananya", 88);
console.log(s.passed());   // true

// The initialisation check in action
class Broken {
  id: number;   // ERROR: Property 'id' has no initializer and is not
                //        definitely assigned in the constructor.
}

// The escape hatch — a promise, not a check
class Injected {
  service!: Student;   // something outside will set this
}
Notes
  • If you find yourself adding ! to several fields, that is usually a sign the object is being constructed in stages. Passing the values into the constructor instead removes the whole class of half-built-object bugs.

Access Modifiers, and Which One Is Real

TypeScript offers three visibility modifiers. public is the default and means anyone may read or write the member. protected restricts access to the class and its subclasses. private restricts it to the declaring class alone.

Here is the part that matters and that most tutorials skip: private is a compile-time rule only. It is erased along with everything else, so the property is an ordinary public property in the emitted JavaScript. Anyone can read it from plain JavaScript, from the browser console, or by casting your object to any. It documents intent and stops honest mistakes; it does not protect a secret.

JavaScript's own private fields, written with a # prefix, are different. They are enforced by the language at runtime, cannot be reached from outside the class by any means, and survive compilation. If a field must genuinely be inaccessible — a token, an internal handle you do not want anyone touching — use #. If you only want to communicate "this is internal, do not depend on it", private is fine and reads better.

One practical difference to be aware of: a # field cannot be used with the parameter-property shorthand in the next section, and it is not accessible in subclasses. protected has no runtime equivalent at all.

Example
class Account {
  public owner: string;
  protected balance: number;
  private auditLog: string[] = [];
  #apiToken: string;              // genuinely private at runtime

  constructor(owner: string, balance: number, token: string) {
    this.owner = owner;
    this.balance = balance;
    this.#apiToken = token;
  }

  deposit(amount: number): void {
    this.balance += amount;
    this.auditLog.push(`+${amount}`);
  }
}

const acc = new Account("Rahul", 5000, "secret-token");

acc.balance;      // ERROR: 'balance' is protected
acc.auditLog;     // ERROR: 'auditLog' is private

// ...but private is only a compile-time rule:
(acc as any).auditLog;     // works at runtime — the data is right there

// # is enforced by JavaScript itself
// (acc as any).apiToken;  // undefined — genuinely unreachable
Notes
  • Because private disappears, it also cannot be used to make two structurally identical classes incompatible in the way you might expect from Java. TypeScript does treat classes with private members nominally for assignability, but that is a type-system rule, not runtime protection.

Parameter Properties and readonly

Writing a field declaration, a constructor parameter and an assignment for every piece of state gets repetitive. TypeScript offers a shorthand: put an access modifier on a constructor parameter and it becomes a field, declared and assigned automatically. This is a TypeScript-only feature with no JavaScript equivalent, and it is used heavily in Angular and NestJS codebases.

The modifier is required for the shorthand to apply. A plain constructor(name: string) is just a parameter; constructor(public name: string) creates and assigns this.name. Forgetting the keyword is a common early mistake, and the error message — a property that does not exist on the class — points at the usage rather than the constructor.

readonly combines with all of this and is worth using liberally. A readonly field can only be set in its declaration or in the constructor, which is exactly right for identity values such as an id or a creation timestamp. Getters give you the other half of controlled access: a computed value that looks like a property, or a read-only view of internal state that outside code should not be able to overwrite.

Example
// The long form
class ProductLong {
  public name: string;
  private sku: string;

  constructor(name: string, sku: string) {
    this.name = name;
    this.sku = sku;
  }
}

// The same class, using parameter properties
class Product {
  constructor(
    public readonly id: number,
    public name: string,
    public price: number,
    private sku: string
  ) {}

  // A getter looks like a property to callers
  get displayPrice(): string {
    return `Rs. ${this.price.toFixed(2)}`;
  }

  // A static member belongs to the class, not to an instance
  static fromRow(row: string): Product {
    const [id, name, price, sku] = row.split(",");
    return new Product(Number(id), name, Number(price), sku);
  }
}

const p = Product.fromRow("1,Mouse,799,LOG-M1");
console.log(p.displayPrice);   // "Rs. 799.00"

p.name = "Wireless Mouse";     // OK
p.id = 2;                      // ERROR: 'id' is a read-only property

implements, and What It Does Not Do

A class can declare implements SomeInterface to state that it satisfies a contract. The compiler then checks that every required member is present with a compatible type, and reports an error on the class if anything is missing. You can implement several interfaces at once.

What surprises people is that implements is only a check. It does not push any type information into the class. A method parameter still needs its own annotation, even though the interface already says what type it should be — unlike a callback assigned to a typed variable, where contextual typing fills the gap. Leave the annotation off and, under strict, you get an implicit-any error rather than the type you expected.

Because of structural typing, implements is optional in a way it is not in Java. A class that happens to have the right shape already satisfies the interface wherever one is expected, whether or not it says so. Writing implements anyway is still worthwhile: it moves the error from every call site to the class itself, which is where the fix belongs.

Finally, remember that a class declaration creates two things at once — a value (the constructor, usable with new) and a type (the shape of an instance). That is why const s: Student and new Student() both work with the same name, and why instanceof works on classes but not on interfaces.

Example
interface Printable {
  print(): string;
}

interface Storable {
  save(target: string): void;
}

class Invoice implements Printable, Storable {
  constructor(public client: string, public amount: number) {}

  print(): string {
    return `${this.client}: Rs. ${this.amount}`;
  }

  // The annotation is still required — implements does not supply it
  save(target: string): void {
    console.log(`Saving to ${target}`);
  }
}

// Structural typing: no 'implements' needed to satisfy Printable
class Receipt {
  print(): string {
    return "receipt";
  }
}

function show(item: Printable) {
  console.log(item.print());
}

show(new Invoice("Acme", 500));   // OK
show(new Receipt());               // also OK

// A class is a type and a value
let inv: Invoice = new Invoice("Acme", 500);
console.log(inv instanceof Invoice);   // true — classes exist at runtime
Notes
  • implements checks the instance side only. It says nothing about static members or the constructor signature, so an interface cannot force a class to have a particular constructor.

Inheritance and Abstract Classes

extends works as it does in JavaScript, with types checked. A subclass inherits members, must call super() before touching this in its constructor, and may override methods — as long as the override remains compatible with the version it replaces.

An abstract class is one you cannot instantiate directly. It exists to be extended. Its abstract members have a signature but no body, and every concrete subclass must supply one. What makes it different from an interface is that an abstract class may also contain real, shared implementation, so you get a contract and common behaviour in one place.

The choice between the two is worth stating plainly. Use an interface when you only need to describe a shape, when unrelated classes should be able to satisfy it, or when plain objects will satisfy it — interfaces cost nothing at runtime and a class can implement several. Use an abstract class when subclasses genuinely share code, and you are willing to spend the single inheritance slot on it.

One safety feature is worth turning on: the override keyword, enforced by the noImplicitOverride compiler option. With it enabled, a method that overrides a parent must say so, so renaming the parent method turns every stale override into an error instead of leaving a method that quietly never runs.

Example
abstract class Shape {
  constructor(public readonly label: string) {}

  abstract area(): number;          // no body — subclasses must supply one

  describe(): string {              // shared implementation
    return `${this.label}: area ${this.area().toFixed(2)}`;
  }
}

class Circle extends Shape {
  constructor(private radius: number) {
    super("circle");                // required before using this
  }

  override area(): number {
    return Math.PI * this.radius ** 2;
  }
}

class Square extends Shape {
  constructor(private side: number) {
    super("square");
  }

  override area(): number {
    return this.side ** 2;
  }
}

const shapes: Shape[] = [new Circle(5), new Square(4)];
shapes.forEach(s => console.log(s.describe()));

new Shape("generic");
// ERROR: Cannot create an instance of an abstract class.
Notes
  • Not everything needs to be a class. Much of modern TypeScript uses plain functions over plain data, with interfaces describing the data and discriminated unions describing the variants. Reach for a class when you have state and behaviour that genuinely belong together — a connection, a store, a service — not because the problem has nouns in it.
Ask AI