TypeScript · lesson 8 of 21
Beginner
6Intermediate
12- 7 Type Aliases in TypeScript
- 8 Classes in TypeScript
- 9 Generics in TypeScript
- 10 Utility Types in TypeScript
- 13 Modules and Declaration Files in TypeScript
- 14 Error Handling in TypeScript
- 15 Async TypeScript
- 16 Testing TypeScript
- 17 Design Patterns in TypeScript
- 18 React with TypeScript
- 19 Node.js with TypeScript
- 21 TypeScript Interview Prep
Also part of TypeScript
2Classes in TypeScript
Access modifiers, private vs #private, parameter properties, getters, abstract classes, statics and implements — shown with real tsc errors and tsx output.
By SkillByExample · Updated
Examples run on TypeScript 6.0.3 and Node 22, each as a standalone file in a folder whose package.json has "type": "module". npx tsx file.ts runs a file; npx tsc --noEmit --module nodenext file.ts type-checks it. TypeScript 6 is strict by default — on 5.x add --strict to get the same errors.
Class Basics
A TypeScript class is a JavaScript class plus type annotations on its properties and methods.
class Animal {
name: string;
constructor(name: string) {
this.name = name;
}
speak(): string {
return `${this.name} makes a sound.`;
}
}
const dog = new Animal("Rex");
console.log(dog.speak());
console.log(dog.color);
$ npx tsc --noEmit --module nodenext animal.ts
animal.ts(15,17): error TS2339: Property 'color' does not exist on type 'Animal'.
Properties must be declared in the class body — assigning this.color in the constructor without a declaration is also an error. Under strict, every declared property must be definitely assigned in the constructor, or have an initialiser.
Access Modifiers
public is the default. private members are only reachable inside the class; protected members also from subclasses.
class Vehicle {
private serial = "VX-100";
protected speed = 0;
accelerate(amount: number): void {
this.speed += amount;
}
}
class Car extends Vehicle {
describe(): string {
return `speed ${this.speed}, serial ${this.serial}`;
}
}
const car = new Car();
car.accelerate(30);
console.log(car.speed);
$ npx tsc --noEmit --module nodenext modifiers.ts
modifiers.ts(12,48): error TS2341: Property 'serial' is private and only accessible within class 'Vehicle'.
modifiers.ts(18,17): error TS2445: Property 'speed' is protected and only accessible within class 'Vehicle' and its subclasses.
Car may use the protected speed but not its parent’s private serial; outside code may use neither.
private versus #private
private is a compile-time rule. The # syntax is a JavaScript feature, enforced by the engine. Compile a class with both and look at the output:
class Account {
private balance = 100;
#pin = "1234";
get summary(): string {
return `balance ${this.balance}, pin ends ${this.#pin.slice(-1)}`;
}
}
const acct = new Account();
console.log(acct.summary);
console.log(JSON.stringify(acct), Object.keys(acct));
console.log((acct as any).balance, (acct as any)["#pin"]);
$ npx tsc --module nodenext --outDir js privacy.ts
$ cat js/privacy.js
class Account {
balance = 100;
#pin = "1234";
get summary() {
return `balance ${this.balance}, pin ends ${this.#pin.slice(-1)}`;
}
}
const acct = new Account();
console.log(acct.summary);
console.log(JSON.stringify(acct), Object.keys(acct));
console.log(acct.balance, acct["#pin"]);
export {};
$ npx tsx privacy.ts
balance 100, pin ends 4
{"balance":100} [ 'balance' ]
100 undefined
The compiled JavaScript still has a plain balance property, so JSON.stringify, Object.keys and an as any cast all see it. #pin isn’t a property with the name "#pin" at all — it’s stored in a slot that code outside the class body can’t name, so the cast finds nothing. Use # when the value must not leak; private is fine for “don’t touch this” organisation.
Parameter Properties
Putting a modifier on a constructor parameter declares the property and assigns it in one step.
class User {
constructor(
public readonly id: number,
public name: string,
private email: string,
) {}
contact(): string {
return `${this.name} <${this.email}>`;
}
}
const user = new User(1, "Alice", "alice@example.com");
user.name = "Alicia";
console.log(user.contact(), user);
user.id = 99;
$ npx tsc --noEmit --module nodenext param-props.ts
param-props.ts(16,6): error TS2540: Cannot assign to 'id' because it is a read-only property.
Remove the last line and run it:
$ npx tsx param-props.ts
Alicia <alice@example.com> User { id: 1, name: 'Alicia', email: 'alice@example.com' }
$ node param-props.ts 2>&1 | grep SyntaxError
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript parameter property is not supported in strip-only mode
Parameter properties are one of the few TypeScript features that generate code (the this.id = id assignments), rather than being erased. tsx and tsc handle them, but Node 22’s built-in type stripping (node file.ts) only deletes type annotations, so it refuses them. The erasableSyntaxOnly compiler option reports the same features at compile time if you plan to run with plain node.
Getters and Setters
Accessors look like properties to callers but run code — useful for validation and derived values.
class Temperature {
#celsius: number;
constructor(celsius: number) {
this.#celsius = celsius;
}
get celsius(): number {
return this.#celsius;
}
set celsius(value: number) {
if (value < -273.15) throw new RangeError(`${value} is below absolute zero`);
this.#celsius = value;
}
get fahrenheit(): number {
return (this.#celsius * 9) / 5 + 32;
}
}
const t = new Temperature(100);
console.log(t.fahrenheit);
t.celsius = 20;
console.log(t.celsius, t.fahrenheit);
try {
t.celsius = -300;
} catch (e) {
console.log((e as RangeError).message, t.celsius);
}
$ npx tsx temperature.ts
212
20 68
-300 is below absolute zero 20
The failed assignment threw before changing anything, so celsius is still 20. A getter with no setter is read-only to the compiler: t.fahrenheit = 50 is rejected with “Cannot assign to ‘fahrenheit’ because it is a read-only property”.
Abstract Classes
An abstract class can’t be instantiated. It supplies shared code and declares abstract members that every subclass must implement.
abstract class Shape {
abstract area(): number;
abstract perimeter(): number;
describe(): string {
return `${this.constructor.name}: area ${this.area().toFixed(2)}, perimeter ${this.perimeter().toFixed(2)}`;
}
}
class Circle extends Shape {
constructor(private radius: number) { super(); }
area() { return Math.PI * this.radius ** 2; }
perimeter() { return 2 * Math.PI * this.radius; }
}
class Square extends Shape {
constructor(private side: number) { super(); }
area() { return this.side ** 2; }
}
const shapes: Shape[] = [new Circle(1), new Square(2)];
shapes.forEach((s) => console.log(s.describe()));
new Shape();
$ npx tsc --noEmit --module nodenext shapes.ts
shapes.ts(16,7): error TS2515: Non-abstract class 'Square' does not implement inherited abstract member perimeter from class 'Shape'.
shapes.ts(23,1): error TS2511: Cannot create an instance of an abstract class.
Square forgot perimeter, and Shape can’t be constructed directly. Add perimeter() { return 4 * this.side; } to Square, drop the last line, and it runs:
$ npx tsx shapes.ts
Circle: area 3.14, perimeter 6.28
Square: area 4.00, perimeter 8.00
describe lives once in Shape and calls whichever area the subclass provides — the template method pattern.
Static Members
static members belong to the class itself rather than to instances. A static factory is a common use.
class Color {
static readonly BLACK = new Color(0, 0, 0);
private static created = 0;
private constructor(readonly r: number, readonly g: number, readonly b: number) {
Color.created++;
}
static fromHex(hex: string): Color {
const n = parseInt(hex.replace("#", ""), 16);
return new Color((n >> 16) & 255, (n >> 8) & 255, n & 255);
}
static get count(): number {
return Color.created;
}
}
const orange = Color.fromHex("#ff8800");
console.log(orange, Color.BLACK, Color.count);
const c = new Color(1, 2, 3);
$ npx tsc --noEmit --module nodenext statics.ts
statics.ts(21,11): error TS2673: Constructor of class 'Color' is private and only accessible within the class declaration.
A private constructor forces callers through fromHex. Remove the last line:
$ npx tsx statics.ts
Color { r: 255, g: 136, b: 0 } Color { r: 0, g: 0, b: 0 } 1
Two colours were constructed, but count is 1. Static fields initialise top to bottom when the class is defined: BLACK runs the constructor first, incrementing created while it’s still undefined (giving NaN), and then created = 0 runs and resets it. Only the fromHex call is counted. The compiler doesn’t flag this ordering; declaring created above BLACK fixes it.
A Class Is a Value and a Type
class Dog declares both a type (the shape of instances) and a value (the constructor). typeof Dog is the type of the constructor.
class Dog {
constructor(public name: string) {}
bark() { return `${this.name}: Woof!`; }
}
function greet(dog: Dog): string {
return dog.bark();
}
function create(Ctor: typeof Dog, name: string): Dog {
return new Ctor(name);
}
class Puppy extends Dog {
bark() { return `${this.name}: Yip!`; }
}
console.log(greet(new Dog("Rex")), create(Puppy, "Bit").bark());
console.log(greet({ name: "Fake", bark: () => "not a dog" }));
$ npx tsc --noEmit --module nodenext class-type.ts
$ npx tsx class-type.ts
Rex: Woof! Bit: Yip!
not a dog
The last line type-checks: Dog as a type is structural, so any object with name and bark qualifies, class or not. If you need “really constructed by Dog”, check instanceof Dog at runtime — or add a #private field, which makes the type nominal because an object literal can’t have one.
A Worked Example: A Generic Base Service
An abstract generic base class provides storage; each subclass adds validation and domain queries.
abstract class BaseService<T extends { id: number }> {
protected items = new Map<number, T>();
abstract validate(item: T): string | null;
save(item: T): T {
const problem = this.validate(item);
if (problem) throw new Error(`invalid ${this.constructor.name} item: ${problem}`);
this.items.set(item.id, item);
return item;
}
findAll(): T[] {
return [...this.items.values()];
}
}
interface User { id: number; name: string; email: string }
class UserService extends BaseService<User> {
validate(user: User) {
return user.email.includes("@") ? null : `bad email ${user.email}`;
}
findByEmail(email: string) {
return this.findAll().find((u) => u.email === email);
}
}
const users = new UserService();
users.save({ id: 1, name: "Alice", email: "alice@example.com" });
console.log(users.findByEmail("alice@example.com"));
try {
users.save({ id: 2, name: "Bob", email: "bob.example.com" });
} catch (e) {
console.log((e as Error).message, users.findAll().length);
}
$ npx tsc --noEmit --module nodenext base-service.ts
$ npx tsx base-service.ts
{ id: 1, name: 'Alice', email: 'alice@example.com' }
invalid UserService item: bad email bob.example.com 1
T extends { id: number } is what lets save read item.id. The UserService subclass inherits save and findAll already typed for User, and this.constructor.name reports the subclass name, not BaseService.
Practice
1. Make Account.balance truly private at runtime and confirm JSON.stringify no longer shows it.
Solution
class Account {
#balance = 100;
deposit(amount: number): void {
this.#balance += amount;
}
get balance(): number {
return this.#balance;
}
}
const acct = new Account();
acct.deposit(50);
console.log(acct.balance, JSON.stringify(acct), Object.keys(acct));
$ npx tsx practice-1.ts
150 {} []
#balance isn’t an own enumerable property, so serialisation and Object.keys skip it, and the getter gives controlled read access. Note the getter itself lives on the prototype, which is why JSON.stringify doesn’t include balance either.
2. Add a Triangle subclass of Shape with sides a, b, c, using Heron’s formula for the area. Check it with a 3-4-5 triangle.
Solution
abstract class Shape {
abstract area(): number;
abstract perimeter(): number;
describe(): string {
return `${this.constructor.name}: area ${this.area().toFixed(2)}, perimeter ${this.perimeter().toFixed(2)}`;
}
}
class Triangle extends Shape {
constructor(private a: number, private b: number, private c: number) { super(); }
perimeter() { return this.a + this.b + this.c; }
area() {
const s = this.perimeter() / 2;
return Math.sqrt(s * (s - this.a) * (s - this.b) * (s - this.c));
}
}
console.log(new Triangle(3, 4, 5).describe());
$ npx tsc --noEmit --module nodenext practice-2.ts
$ npx tsx practice-2.ts
Triangle: area 6.00, perimeter 12.00
A 3-4-5 right triangle has area 3 × 4 / 2 = 6, which confirms the formula. describe needed no change.
3. Make Dog nominal: add a private brand so that an object literal with name and bark is no longer accepted by greet.
Solution
class Dog {
#brand = true;
constructor(public name: string) {}
bark() { return `${this.name}: Woof!`; }
}
function greet(dog: Dog): string {
return dog.bark();
}
console.log(greet(new Dog("Rex")));
console.log(greet({ name: "Fake", bark: () => "not a dog" }));
$ npx tsc --noEmit --module nodenext practice-3.ts
practice-3.ts(12,19): error TS2345: Argument of type '{ name: string; bark: () => string; }' is not assignable to parameter of type 'Dog'.
Property '#brand' is missing in type '{ name: string; bark: () => string; }' but required in type 'Dog'.
A class with a #private (or TypeScript private) member is only compatible with instances of itself and its subclasses, because no other declaration can produce that member.