Typed fields and constructors

Everything you know about JavaScript classes still applies: constructors and methods, fields, getters and setters, static members, extends and super and this. TypeScript adds one rule: every field must be declared in the class body, with a type. In JavaScript you may create a property by assigning this.city = … anywhere; in TypeScript that assignment is an error unless city is declared as a field.

class Meter {
  id: string;
  readings: number[] = [];

  constructor(id: string) {
    this.id = id;
  }

  addReading(kWh: number): void {
    this.readings.push(kWh);
  }

  total(): number {
    return this.readings.reduce((sum, r) => sum + r, 0);
  }
}

const meter = new Meter("E-1001");
meter.addReading(4.2);
meter.addReading(3.1);
console.log(meter.id, meter.total().toFixed(1));
E-1001 7.3

The class name is also a type: Meter describes every object made by new Meter(…), so the compiler checks each method call against the declared parameter types.

class Meter {
  readings: number[] = [];

  addReading(kWh: number): void {
    this.readings.push(kWh);
  }
}

const meter = new Meter();
meter.addReading("5.0");
example.ts(10,18): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.

In strict mode a field must also get a value: either an initializer (readings: number[] = []) or an assignment in the constructor. A field that is declared but never set is reported, because reading it would give undefined while its type promises a string:

class Ticket {
  id: string;
  title: string;

  constructor(id: string) {
    this.id = id;
  }
}
example.ts(3,3): error TS2564: Property 'title' has no initializer and is not definitely assigned in the constructor.

The fixes (an initializer, an assignment, an optional field title?: string, or the definite assignment assertion title!: string when a framework fills it in) are explained in null, undefined and Strict Null Checks.

public, private and protected

An access modifier in TypeScript is one of the keywords public, private or protected in front of a field, method or constructor parameter. It tells the type checker which code may use that member:

  • public (the default, usually not written): any code.
  • private: only code inside the same class.
  • protected: code inside the class and its subclasses.

The idea is the general one of encapsulation, and the keywords look exactly like C#'s and Java's. Using a private member from outside is a compile-time error:

class Customer {
  name: string;
  private iban: string;

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

const customer = new Customer("Amara Okafor", "NL91ABNA0417164300");
console.log(customer.iban);
example.ts(12,22): error TS2341: Property 'iban' is private and only accessible within class 'Customer'.

protected opens a member to subclasses, but still not to the outside world:

class Meter {
  protected readings: number[] = [4.2, 3.1];
}

class SmartMeter extends Meter {
  latest(): number | undefined {
    return this.readings.at(-1);   // allowed: SmartMeter is a subclass
  }
}

const meter = new SmartMeter();
console.log(meter.latest());
console.log(meter.readings);
example.ts(13,19): error TS2445: Property 'readings' is protected and only accessible within class 'Meter' and its subclasses.

private or #private?

Here TypeScript differs sharply from C# and Java. Types are erased when the code is compiled, and so are the access modifiers: in the emitted JavaScript a private field is an ordinary property. Anything that does not go through the type checker — plain JavaScript code, a JSON.stringify, a debugger, a cast to any — sees it. JavaScript's own private fields, written with #, are enforced by the JavaScript engine at run time. TypeScript checks those too:

class Customer {
  name: string;
  private iban: string;     // private for the type checker only
  #phone: string;           // private at run time as well

  constructor(name: string, iban: string, phone: string) {
    this.name = name;
    this.iban = iban;
    this.#phone = phone;
  }
}

const customer = new Customer("Amara Okafor", "NL91ABNA0417164300", "+31 30 555 0142");
console.log(Object.keys(customer));
console.log(JSON.stringify(customer));
[ 'name', 'iban' ]
{"name":"Amara Okafor","iban":"NL91ABNA0417164300"}

Which to use? #private is real encapsulation and the JavaScript standard (ES2022), so it is a good default for new code. private is still very common in existing code and in frameworks, it can be used in parameter properties (below), and tests can still reach the member when they really have to. Both are fine; mixing them in one class is confusing.

readonly fields

readonly means a field can only be assigned in its declaration or in the constructor. Like the access modifiers, it is checked at compile time and gone at run time; it is the same idea as PHP's readonly properties, which PHP does enforce while the program runs.

class Tariff {
  readonly code: string;

  constructor(code: string) {
    this.code = code;        // allowed: inside the constructor
  }
}

const tariff = new Tariff("DAY");
tariff.code = "NIGHT";
example.ts(10,8): error TS2540: Cannot assign to 'code' because it is a read-only property.

Parameter properties

Many constructors do nothing but copy their parameters into fields. A parameter property is a TypeScript shorthand for that: put public, private, protected or readonly in front of a constructor parameter, and TypeScript declares a field with that name and assigns the argument to it.

class Ticket {
  constructor(
    public readonly id: string,
    private title: string,
    protected priority: number = 3,
  ) {}

  describe(): string {
    return `${this.id} (P${this.priority}): ${this.title}`;
  }
}

const ticket = new Ticket("INC-10234", "No power in Utrecht", 1);
console.log(ticket.describe());
console.log(ticket.id);
INC-10234 (P1): No power in Utrecht
INC-10234

The empty constructor body is not a mistake: the compiler writes the fields and assignments for you. This is the JavaScript it emits (TypeScript 7.0, target ES2023, without the two CommonJS header lines):

class Ticket {
    id;
    title;
    priority;
    constructor(id, title, priority = 3) {
        this.id = id;
        this.title = title;
        this.priority = priority;
    }
    describe() {
        return `${this.id} (P${this.priority}): ${this.title}`;
    }
}

That is the catch. Almost all TypeScript syntax can simply be deleted to get JavaScript; a parameter property cannot, because deleting public readonly would also delete the fields and the assignments. It is not erasable syntax, so Node.js type stripping refuses it, and the compiler option erasableSyntaxOnly reports it as an error (details in why enums are not erasable). Code that must run through type stripping writes the fields out in full.

Inheritance and override

extends and super work as in JavaScript. A subclass may replace a method of its parent; the override modifier marks such a method explicitly, so the compiler can check that the parent really has a member with that name.

class Meter {
  constructor(public id: string) {}

  describe(): string {
    return `meter ${this.id}`;
  }
}

class SmartMeter extends Meter {
  constructor(id: string, public intervalMinutes: number) {
    super(id);
  }

  override describe(): string {
    return `${super.describe()}, reads every ${this.intervalMinutes} min`;
  }
}

const meters: Meter[] = [new Meter("E-1001"), new SmartMeter("E-1002", 15)];
for (const m of meters) {
  console.log(m.describe());
}
meter E-1001
meter E-1002, reads every 15 min

The array has type Meter[], yet each object runs its own describe: that is polymorphism, and it comes from JavaScript's prototype chain, not from TypeScript. What override adds is protection against a renamed or misspelled method, which would otherwise silently become a new method next to the parent's:

class Meter {
  report(): string { return "meter"; }
}

class SmartMeter extends Meter {
  override reprot(): string { return "smart meter"; }
}
example.ts(6,12): error TS4117: This member cannot have an 'override' modifier because it is not declared in the base class 'Meter'. Did you mean 'report'?

override is optional by default. The compiler option noImplicitOverride (not part of strict) makes it required, so a method that replaces a parent's method without saying so is an error too (meter.ts is the example above with the typo fixed and override removed):

npx tsc --ignoreConfig --noImplicitOverride --noEmit meter.ts
meter.ts(6,3): error TS4114: This member must have an 'override' modifier because it overrides a member in the base class 'Meter'.

Abstract classes

An abstract class is a base class that is only meant to be extended. In TypeScript you write abstract class, and inside it abstract methods that have a signature but no body. Every concrete subclass must implement them; the shared logic lives in the base class.

abstract class Notifier {
  abstract send(to: string, text: string): string;

  notifyOutage(to: string, ticketId: string): void {
    console.log(this.send(to, `${ticketId}: power outage in your area`));
  }
}

class EmailNotifier extends Notifier {
  send(to: string, text: string): string {
    return `email to ${to}: ${text}`;
  }
}

class SmsNotifier extends Notifier {
  send(to: string, text: string): string {
    return `SMS to ${to}: ${text.slice(0, 20)}…`;
  }
}

const notifiers: Notifier[] = [new EmailNotifier(), new SmsNotifier()];
for (const n of notifiers) {
  n.notifyOutage("customer 4471", "INC-10234");
}
email to customer 4471: INC-10234: power outage in your area
SMS to customer 4471: INC-10234: power out…

The compiler enforces both halves of the contract. You cannot create an instance of the abstract class itself, and a subclass that forgets an abstract method is reported:

abstract class Notifier {
  abstract send(to: string, text: string): string;
}

const n = new Notifier();
example.ts(5,11): error TS2511: Cannot create an instance of an abstract class.
abstract class Notifier {
  abstract send(to: string, text: string): string;
}

class PushNotifier extends Notifier {
  // send() is missing
}
example.ts(5,7): error TS2515: Non-abstract class 'PushNotifier' does not implement inherited abstract member send from class 'Notifier'.

abstract is erased as well: in the JavaScript output Notifier is a normal class, and JavaScript code could still call new Notifier().

Implementing interfaces

An implements clause, class Invoice implements Billable, asks the compiler to check that the class has every member the interface requires, with compatible types. A class can implement several interfaces, separated by commas, and can extend one class at the same time.

interface Billable {
  id: string;
  amount(): number;
}

class Invoice implements Billable {
  constructor(public id: string) {}
  amout(): number { return 0; }      // typo
}
example.ts(6,7): error TS2420: Class 'Invoice' incorrectly implements interface 'Billable'.

The full message continues with the reason: Property 'amount' is missing in type 'Invoice' but required in type 'Billable'. Note what implements does not do. It does not make the class a Billable; it only checks that it already is one. Because TypeScript compares shapes, any object with the right members is a Billable, with or without the clause:

interface Billable {
  id: string;
  amount(): number;
}

class Invoice implements Billable {
  constructor(public id: string, private kWh: number, private price: number) {}
  amount(): number {
    return this.kWh * this.price;
  }
}

const connectionFee = { id: "FEE-2026", amount: () => 4.5 };   // no class at all

function total(items: Billable[]): string {
  return items.reduce((sum, item) => sum + item.amount(), 0).toFixed(2);
}

console.log(total([new Invoice("INV-0091", 250, 0.32), connectionFee]));
84.50

An interface exists only for the type checker, so you cannot test for it at run time:

interface Billable {
  amount(): number;
}

function isBillable(x: object): boolean {
  return x instanceof Billable;
}
example.ts(6,23): error TS2693: 'Billable' only refers to a type, but is being used as a value here.

Checking the shape at run time is done with a type guard, see Narrowing and Type Guards.

Classes are structural too

A class name is a type, and that type is compared by shape like every other type. An object literal that has all the public members of Reading is accepted where a Reading is expected, even though it was never created with new, and instanceof then tells the truth:

class Reading {
  constructor(public meterId: string, public kWh: number) {}
}

const fromClass: Reading = new Reading("E-1001", 4.2);
const fromLiteral: Reading = { meterId: "E-1002", kWh: 3.1 };   // compiles

console.log(fromClass instanceof Reading, fromLiteral instanceof Reading);
true false

So code that receives a Reading should not assume it can use instanceof Reading. The exception is a class with private or protected members: those only match objects created from that same class (or a subclass), so the literal no longer fits:

class Customer {
  constructor(public name: string, private iban: string) {}
}

const c: Customer = { name: "Amara Okafor", iban: "NL91ABNA0417164300" };
example.ts(5,7): error TS2322: Type '{ name: string; iban: string; }' is not assignable to type 'Customer'.

What survives compilation

A quick way to remember which class features are real at run time:

Feature Checked by tsc In the emitted JavaScript
Field and parameter types yes removed
public, private, protected, readonly yes removed (an ordinary property remains)
abstract, implements, override yes removed
Parameter properties yes turned into fields and assignments
Decorators yes turned into helper code (below)
class, fields, methods, static, #private, extends yes kept: these are JavaScript

Decorators

A decorator is a function, applied with @name in front of a class or class member, that runs when the class is defined and can replace or wrap the thing it decorates. They look like Java annotations or C# attributes, but those are mostly passive metadata; a decorator is code that runs. A typical use is cross-cutting behavior such as logging, here wrapping a method:

function logged(
  method: (this: Billing, ...args: number[]) => number,
  context: ClassMethodDecoratorContext,
) {
  const name = String(context.name);
  return function (this: Billing, ...args: number[]): number {
    console.log(`calling ${name}(${args.join(", ")})`);
    return method.call(this, ...args);
  };
}

class Billing {
  @logged
  cost(kWh: number): number {
    return kWh * 0.32;
  }
}

console.log(new Billing().cost(10).toFixed(2));
calling cost(10)
3.20

TypeScript has supported this standard form of decorators since version 5.0, compiling them into plain JavaScript with a set of helper functions: the small example above becomes about 60 lines of JavaScript. The older form, switched on with the compiler option experimentalDecorators, follows an earlier design with different rules; many existing projects and frameworks that adopted decorators early still use it, so check a project's tsconfig.json before you write one.