Classes
Classes in TypeScript: typed fields and constructors, public, private and protected, parameter properties, readonly, abstract classes, implementing interfaces, and #private fields.
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.