Classes
Everything from the OOP course, now checkable. Including the awkward truth that TypeScript has two different kinds of private and only one of them is real.
By the end: Type a class hierarchy, and choose between private and #.
If you did the object-oriented JavaScript course, this lesson is that material with the checking switched on. Everything you learned about constructors, prototypes, inheritance and encapsulation still holds exactly. What changes is that the compiler now enforces the contracts you were previously keeping in your head.
There is also one genuinely awkward thing to get straight, and it is best dealt with early: TypeScript has two different kinds of private, and only one of them is real.
Fields, and the two ways to declare them
// The explicit form: declare the fields, assign them in the constructor.
class Explicit {
name: string;
age: number;
active = true; // initialiser, so the type is inferred
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
}
// Parameter properties: the modifier on the parameter declares AND assigns
// the field. Less typing, and one of the few places TypeScript emits code
// that is not simply erased.
class Shorthand {
active = true;
constructor(
public name: string,
public age: number,
) {}
}
const a = new Explicit('Ada', 36);
const b = new Shorthand('Grace', 45);
console.log(a.name, a.age, a.active);
console.log(b.name, b.age, b.active);
Output
class Point {
constructor(
public x: number,
public y: number,
) {}
distanceFromOrigin(): number {
return Math.sqrt(this.x ** 2 + this.y ** 2);
}
}
const p = new Point(3, 4);
console.log(p.x, p.y, p.distanceFromOrigin());
What this compiles to
class Point {
x;
y;
constructor(x, y) {
this.x = x;
this.y = y;
}
distanceFromOrigin() {
return Math.sqrt(this.x ** 2 + this.y ** 2);
}
}
const p = new Point(3, 4);
console.log(p.x, p.y, p.distanceFromOrigin());
Output
The two kinds of private
This is the part worth getting right. TypeScript's private keyword is a compile-time annotation. JavaScript's # prefix is a runtime feature. They look interchangeable and behave very differently.
class Account {
private balanceTs = 0; // TypeScript private
#balanceJs = 0; // JavaScript private
deposit(amount: number): void {
this.balanceTs += amount;
this.#balanceJs += amount;
}
report(): string {
return `ts=${this.balanceTs} js=${this.#balanceJs}`;
}
}
const account = new Account();
account.deposit(100);
console.log(account.report());
// The TypeScript private is gone at run time, so it is reachable from
// JavaScript that does not know it was supposed to be off limits.
const asAny = account as unknown as Record<string, unknown>;
console.log('balanceTs is visible:', asAny['balanceTs']);
// The JavaScript private genuinely is not there.
console.log('own property names:', Object.getOwnPropertyNames(account));
console.log('JSON sees only one of them:', JSON.stringify(account));
What this compiles to
class Account {
balanceTs = 0; // TypeScript private
#balanceJs = 0; // JavaScript private
deposit(amount) {
this.balanceTs += amount;
this.#balanceJs += amount;
}
report() {
return `ts=${this.balanceTs} js=${this.#balanceJs}`;
}
}
const account = new Account();
account.deposit(100);
console.log(account.report());
// The TypeScript private is gone at run time, so it is reachable from
// JavaScript that does not know it was supposed to be off limits.
const asAny = account;
console.log('balanceTs is visible:', asAny['balanceTs']);
// The JavaScript private genuinely is not there.
console.log('own property names:', Object.getOwnPropertyNames(account));
console.log('JSON sees only one of them:', JSON.stringify(account));
Output
Both are checked by the compiler. Only one survives.
class Account {
private balanceTs = 0;
#balanceJs = 0;
deposit(amount: number): void {
this.balanceTs += amount;
this.#balanceJs += amount;
}
}
const account = new Account();
account.deposit(10);
console.log(account.balanceTs);
What the compiler says
error TS2341: Property 'balanceTs' is private and only accessible within class 'Account'.
readonly, protected, and static
class Entity {
// readonly: assignable in the constructor, never afterwards.
readonly id: string;
// protected: reachable from this class and its subclasses, not outside.
protected createdAt: Date;
// static: on the class, not on instances.
static count = 0;
// A static method, and a private static helper.
static nextId(): string {
return `e${++Entity.count}`;
}
constructor() {
this.id = Entity.nextId();
this.createdAt = new Date('2026-01-01T00:00:00Z');
}
}
class Document2 extends Entity {
constructor(public title: string) {
super();
}
// A subclass can reach protected members.
age(now: Date): number {
return now.getTime() - this.createdAt.getTime();
}
}
const doc = new Document2('Notes');
console.log(doc.id, doc.title);
console.log('static count:', Entity.count);
console.log('age in ms:', doc.age(new Date('2026-01-02T00:00:00Z')));
Output
class Entity {
protected createdAt = new Date();
}
const entity = new Entity();
console.log(entity.createdAt);
What the compiler says
error TS2445: Property 'createdAt' is protected and only accessible within class 'Entity' and its subclasses.
implements: promising to match a shape
An interface describes a shape. implements declares that a class provides that shape, and asks the compiler to check it. This is the mechanism behind “program to an interface” from the OOP course, and it is the thing TypeScript adds that JavaScript could not.
interface Serialiser {
readonly name: string;
serialise(value: unknown): string;
}
class JsonSerialiser implements Serialiser {
readonly name = 'json';
serialise(value: unknown): string {
return JSON.stringify(value);
}
}
class QuerySerialiser implements Serialiser {
readonly name = 'query';
serialise(value: unknown): string {
if (typeof value !== 'object' || value === null) return String(value);
return Object.entries(value)
.map(([k, v]) => `${k}=${String(v)}`)
.join('&');
}
}
// The calling code depends on the interface, not on either class.
function send(serialiser: Serialiser, payload: unknown): string {
return `[${serialiser.name}] ${serialiser.serialise(payload)}`;
}
const payload = { id: 7, name: 'Ada' };
for (const s of [new JsonSerialiser(), new QuerySerialiser()]) {
console.log(send(s, payload));
}
Output
interface Serialiser {
readonly name: string;
serialise(value: unknown): string;
}
// No `name` property. The compiler reports the class as a whole.
class Broken implements Serialiser {
serialise(value: unknown): string {
return String(value);
}
}
console.log(new Broken().serialise('x'));
What the compiler says
error TS2420: Class 'Broken' incorrectly implements interface 'Serialiser'.
A member that exists but has the wrong signature produces a different, more specific message — naming the property rather than the class.
interface Serialiser {
serialise(value: unknown): string;
}
class Broken implements Serialiser {
// Right name, wrong return type.
serialise(value: unknown): number {
return String(value).length;
}
}
console.log(new Broken().serialise('x'));
What the compiler says
error TS2416: Property 'serialise' in type 'Broken' is not assignable to the same property in base type 'Serialiser'.
interface Greeter {
greet(name: string): string;
}
// Says implements. Checked at the class.
class Polite implements Greeter {
greet(name: string): string { return `Good day, ${name}`; }
}
// Says nothing. Still accepted, because the shape matches.
class Blunt {
greet(name: string): string { return `Oi, ${name}`; }
}
function welcome(greeter: Greeter, name: string): string {
return greeter.greet(name);
}
console.log(welcome(new Polite(), 'Ada'));
console.log(welcome(new Blunt(), 'Grace'));
// Even a plain object works, because none of this is nominal.
console.log(welcome({ greet: (n) => `Hello ${n}` }, 'Alan'));
Output
abstract classes
An abstract class is a partial implementation: shared behaviour plus a list of things subclasses must provide. Where an interface has no implementation at all, an abstract class can carry both.
abstract class Shape {
constructor(public readonly label: string) {}
// No body: every subclass must supply one.
abstract area(): number;
// A concrete method, shared by all subclasses, that uses the abstract one.
describe(): string {
return `${this.label} has area ${this.area().toFixed(2)}`;
}
}
class Circle extends Shape {
constructor(private radius: number) {
super('circle');
}
override area(): number {
return Math.PI * this.radius ** 2;
}
}
class Rectangle extends Shape {
constructor(
private width: number,
private height: number,
) {
super('rectangle');
}
override area(): number {
return this.width * this.height;
}
}
const shapes: Shape[] = [new Circle(1), new Rectangle(3, 4)];
for (const shape of shapes) console.log(shape.describe());
Output
abstract class Shape {
abstract area(): number;
}
const shape = new Shape();
console.log(shape.area());
What the compiler says
error TS2511: Cannot create an instance of an abstract class.
abstract class Shape {
abstract area(): number;
abstract perimeter(): number;
}
class Square extends Shape {
constructor(private side: number) { super(); }
override area(): number { return this.side ** 2; }
}
console.log(new Square(2).area());
What the compiler says
error TS2515: Non-abstract class 'Square' does not implement inherited abstract member perimeter from class 'Shape'.
override, and the flag that makes it mean something
noImplicitOverride is on for this course. It requires the override keyword whenever you replace an inherited member, which catches a specific and genuinely nasty bug: renaming a method in a base class and silently orphaning the subclass version.
class Base {
greet(): string { return 'hello'; }
}
class Child extends Base {
// This replaces Base.greet, and with noImplicitOverride on you have to say
// that on purpose.
greet(): string { return 'hi'; }
}
console.log(new Child().greet());
What the compiler says
error TS4114: This member must have an 'override' modifier because it overrides a member in the base class 'Base'.
class Base {
greet(): string { return 'hello'; }
}
class Child extends Base {
// Claims to override, but the base has no `salute`. This is what catches a
// rename in the base class: the subclass stops compiling instead of
// quietly becoming dead code.
override salute(): string { return 'hi'; }
}
console.log(new Child().salute());
What the compiler says
error TS4113: This member cannot have an 'override' modifier because it is not declared in the base class 'Base'.
Generic classes, and this typing
class Collection<T> {
#items: T[] = [];
add(item: T): this {
this.#items.push(item);
return this; // `this`, not Collection<T>, so chaining works
} // correctly in subclasses too
get size(): number {
return this.#items.length;
}
toArray(): readonly T[] {
return this.#items;
}
}
// A subclass adds a method, and chaining still returns the subclass type.
class NumberCollection extends Collection<number> {
sum(): number {
return this.toArray().reduce((a, b) => a + b, 0);
}
}
const names = new Collection<string>().add('Ada').add('Grace');
console.log(names.size, names.toArray());
// Because add() returns `this`, sum() is still available after chaining.
const numbers = new NumberCollection().add(1).add(2).add(3);
console.log(numbers.sum(), numbers.size);
Output
Classes as types, and instanceof
class User {
constructor(
public id: number,
public name: string,
) {}
}
// The class name works as a type for instances.
function describe(user: User): string {
return `#${user.id} ${user.name}`;
}
// And it is a real value, so instanceof works — unlike an interface.
function isUser(value: unknown): boolean {
return value instanceof User;
}
const real = new User(1, 'Ada');
const lookalike = { id: 2, name: 'Grace' };
console.log(describe(real));
// Structural typing means the plain object is accepted as a User...
console.log(describe(lookalike));
// ...but instanceof asks a different question, about the prototype chain.
console.log('real:', isUser(real), '| lookalike:', isUser(lookalike));
Output
class Token {
// A #field cannot be replicated by an object literal, so this class no
// longer accepts structural lookalikes.
#brand = true;
constructor(public value: string) {}
}
function use(token: Token): string {
return token.value;
}
console.log(use({ value: 'looks-like-a-token' }));
What the compiler says
error TS2741: Property '#brand' is missing in type '{ value: string; }' but required in type 'Token'.
Putting it together
interface Repository<T> {
save(item: T): void;
findById(id: number): T | undefined;
all(): readonly T[];
}
abstract class InMemoryRepository<T extends { id: number }> implements Repository<T> {
#items = new Map<number, T>();
save(item: T): void {
this.#items.set(item.id, item);
}
findById(id: number): T | undefined {
return this.#items.get(id);
}
all(): readonly T[] {
return [...this.#items.values()];
}
abstract describe(item: T): string;
report(): string {
return this.all().map((item) => this.describe(item)).join('; ');
}
}
interface Product {
id: number;
sku: string;
price: number;
}
class ProductRepository extends InMemoryRepository<Product> {
override describe(product: Product): string {
return `${product.sku} at £${product.price}`;
}
cheaperThan(limit: number): readonly Product[] {
return this.all().filter((p) => p.price < limit);
}
}
const repo = new ProductRepository();
repo.save({ id: 1, sku: 'desk', price: 250 });
repo.save({ id: 2, sku: 'chair', price: 90 });
repo.save({ id: 3, sku: 'lamp', price: 40 });
console.log(repo.findById(2)?.sku);
console.log(repo.report());
console.log('under 100:', repo.cheaperThan(100).map((p) => p.sku));
What this compiles to
class InMemoryRepository {
#items = new Map();
save(item) {
this.#items.set(item.id, item);
}
findById(id) {
return this.#items.get(id);
}
all() {
return [...this.#items.values()];
}
report() {
return this.all().map((item) => this.describe(item)).join('; ');
}
}
class ProductRepository extends InMemoryRepository {
describe(product) {
return `${product.sku} at £${product.price}`;
}
cheaperThan(limit) {
return this.all().filter((p) => p.price < limit);
}
}
const repo = new ProductRepository();
repo.save({ id: 1, sku: 'desk', price: 250 });
repo.save({ id: 2, sku: 'chair', price: 90 });
repo.save({ id: 3, sku: 'lamp', price: 40 });
console.log(repo.findById(2)?.sku);
console.log(repo.report());
console.log('under 100:', repo.cheaperThan(100).map((p) => p.sku));
Output
Your turn
- Write a
BankAccountclass with a balance that cannot be read or written from outside. Do it with#, then checkJSON.stringifyandObject.keyson an instance to confirm the field is genuinely absent. - Rewrite the same class with
privateinstead, then reach the field anyway by casting the instance toRecord<string, unknown>. Decide which version you would ship. - Write an interface with two methods and a class that
implementsit but gets one return type wrong. Read the error and note that it points at the class. - Write an abstract class with one concrete method and one abstract method, then a subclass that forgets the abstract one. Read the error code.
- Add a method to a subclass that shadows a base method without
override. Read the error. Then rename the base method and confirm theoverridekeyword catches the orphan. - Write a fluent class whose
addreturnsthis, subclass it, and confirm you can chain and then call the subclass method. Change the return type to the class name and watch it break.
Recap
- Everything from the OOP course still applies. What TypeScript adds is that the contracts are now checked.
privateis a compile-time annotation and is reachable at run time;#is a real JavaScript feature and is not. Use#when the privacy matters.- Parameter properties (
constructor(public x: number)) emit code rather than being erased, which is whynode file.tsrejects them. implementsadds nothing and checks everything. Because typing is structural, it is optional — but it puts the error on the class rather than at every call site.- Prefer an interface to an abstract class unless there is real shared implementation to inherit.
noImplicitOverridemakesoverridemandatory, which turns a renamed base method from silent dead code into a compile error.- Return
thisrather than the class name from fluent methods, so chaining survives subclassing.