Lesson 5

Classes & OOP

Home › Courses › TypeScript › Lesson 5
🔷 Lesson 5 · 18 minutes

Classes & OOP.
Runtime identity, when you need it.

TypeScript classes are JavaScript classes with types and access modifiers on top. They're the only construct that produces a runtime value and a type at the same time — which makes them essential for some patterns (Error subclasses, service objects, framework components) and unnecessary for others (data shapes, props). This lesson covers access modifiers, implements vs extends, abstract classes, parameter properties, and the most important question: when should you actually reach for a class in a TypeScript codebase?

~18 minutes Intermediate TypeScript 5 · classes
TypeScript path Class basics Access modifiers Parameter properties implements vs extends Abstract classes When to use classes Challenge

Access modifiers: public (default), private (class only), protected (class + subclasses). readonly blocks reassignment. Parameter properties — constructor(public name: string) — declare a field and assign it in one line. extends inherits implementation; implements satisfies a contract without inheriting. Abstract classes can't be instantiated — they exist only to be extended. Critical caveat: TypeScript's private is compile-time only — use #field for real runtime privacy.

Before this lesson: complete Lesson 4 — you'll use instanceof narrowing throughout.

🧱 Class basics — the JavaScript you know, plus types

Same syntax as JS, plus typed fields, methods, and parameters.

A TypeScript class is a JavaScript class with type annotations on fields, methods, and constructor parameters. The runtime behavior is identical — the types are erased at compile time. If you know JavaScript classes, you already know 80% of TypeScript classes.

basics.ts
// ─── A typed class ────────────────────────────
class User {
// Field declarations with types
id: string;
name: string;
email: string;
createdAt: Date;

// Constructor with typed parameters
constructor(id: string, name: string, email: string) {
this.id = id;
this.name = name;
this.email = email;
this.createdAt = new Date();
}

// Typed methods
greet(): string {
return Hi, I'm ${this.name}`;
}

rename(newName: string): void {
this.name = newName;
}
}

// ─── Using the class ──────────────────────────
const alice = new User('u_1', 'Alice', 'a@example.com');
alice.rename('Alicia');

// ─── Classes are types AND values ─────────────
// User is a type (for annotations) and a value (for new).
function greetUser(u: User): string {
return u.greet();
}

// ─── instanceof works — classes are runtime ────
if (alice instanceof User) {
console.log('confirmed User instance');
}

console.log(greetUser(alice));
console.log(alice.createdAt instanceof Date);
// Field declarations and type annotations erased. Runtime is a standard JS class.

class User {
constructor(id, name, email) {
this.id = id;
this.name = name;
this.email = email;
this.createdAt = new Date();
}

greet() {
return Hi, I'm${this.name}`;
}

rename(newName) {
this.name = newName;
}
}

const alice = new User('u_1', 'Alice', 'a@example.com');
alice.rename('Alicia');

function greetUser(u) {
return u.greet();
}

if (alice instanceof User) {
console.log('confirmed User instance');
}

console.log(greetUser(alice));
console.log(alice.createdAt instanceof Date);
Hi, I'm Alicia
confirmed User instance
Hi, I'm Alicia
true

Type + value

Unlike interfaces, a class produces both a type (for annotations like u: User) and a runtime value (for new User()). This is why classes can be used with instanceof.

Fields must be declared

TypeScript requires class fields to be declared before use (unlike JS, where you can this.x = 1 freely). Declare at the top — or use parameter properties (next section) to skip this boilerplate.

Method typing

Methods are typed like functions: parameter types, return types. greet(): string means it takes no args and returns a string. Methods get inference too, but explicit return types help readability.

this is typed

Inside the class, this is typed as the class itself. Autocomplete works on this.name, this.greet(), etc. This is one of the biggest wins over plain JS — no more typos.

In React code, you'll almost never write a class. Modern React uses function components and hooks — class components are legacy. But you'll still use classes for: (1) Error subclasses (class ValidationError extends Error), (2) service objects (class ApiClient), (3) framework constructs (Angular, NestJS), and (4) state machines with a clear lifecycle. If you're writing React UI, skip classes. If you're writing backend services or libraries, you'll need them. The skill is knowing which.

🔒 Access modifiers — public, private, protected

Control what the outside world can touch. Compile-time only.

Access modifiers restrict where a class member can be accessed. public is the default — anywhere. private restricts to the class itself. protected restricts to the class and its subclasses. TypeScript enforces these at compile time, catching bugs before they run.

modifiers.ts
class Account {
readonly id: string; // public by default, read-only
public owner: string; // explicit public (same as default)
private balance: number = 0; // only this class can read/write
protected history: string[] = []; // this class + subclasses

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

deposit(amount: number): void {
if (amount <= 0) throw new Error('Invalid amount');
this.balance += amount;
this.history.push(+${amount}`);
}

getBalance(): number {
return this.balance;
}
}

class SavingsAccount extends Account {
interestRate: number;

constructor(id: string, owner: string, rate: number) {
super(id, owner);
this.interestRate = rate;
}

showHistory(): string[] {
// ✅ protected is accessible in subclasses
return this.history;
// return this.balance; // ❌ private is NOT accessible
}
}

const a = new Account('a1', 'Alice');
a.deposit(100);
console.log(a.getBalance());
// console.log(a.balance); // ❌ Property 'balance' is private
// a.balance = 999; // ❌ Cannot assign to private

const s = new SavingsAccount('a2', 'Bob', 0.05);
s.deposit(200);
console.log(s.showHistory());
// Access modifiers erased. Runtime has no notion of private/protected.

class Account {
constructor(id, owner) {
this.balance = 0;
this.history = [];
this.id = id;
this.owner = owner;
}

deposit(amount) {
if (amount <= 0) throw new Error('Invalid amount');
this.balance += amount;
this.history.push(+${amount}`);
}

getBalance() {
return this.balance;
}
}

class SavingsAccount extends Account {
constructor(id, owner, rate) {
super(id, owner);
this.interestRate = rate;
}

showHistory() {
return this.history;
}
}

const a = new Account('a1', 'Alice');
a.deposit(100);
console.log(a.getBalance());

const s = new SavingsAccount('a2', 'Bob', 0.05);
s.deposit(200);
console.log(s.showHistory());
100
[ '+200' ]

public is the default

Members are public unless marked otherwise. Writing public explicitly is often just for clarity — it doesn't change anything.

private — class only

Accessible only inside the class body. Subclasses cannot access private members — even via super. Use it for internal invariants that shouldn't leak out.

protected — class + subclasses

Accessible inside the class and any subclass. Use it for extension points — members subclasses might need to override or use in their own methods.

readonly is orthogonal

readonly can combine with any modifier: public readonly id, private readonly token. It blocks reassignment after construction — separate concern from access.

TypeScript's private is compile-time only — it's not real privacy. At runtime, the output is a plain JavaScript object with no access control. Anyone can do obj['balance'] or (obj as any).balance and access it. For genuine runtime privacy, use JavaScript's #private fields (ES2022): class A { #secret = 'x'; get() { return this.#secret; } }. TypeScript supports # syntax directly. Choose based on intent: private for "please don't touch" and # for "cannot touch."

⚡ Parameter properties — skip the boilerplate

Declare and assign a field in one line. A TypeScript-only shorthand.

Without parameter properties, every field requires two lines: a declaration and a constructor assignment. Parameter properties collapse them: put an access modifier on a constructor parameter, and TypeScript generates both the field and the assignment. This is a TypeScript feature — the compiled output has the assignment but not the type declaration.

paramprops.ts
// ─── Without parameter properties (verbose) ──
class VerboseUser {
readonly id: string;
name: string;
private email: string;
protected role: 'user' | 'admin';

constructor(
id: string,
name: string,
email: string,
role: 'user' | 'admin'
) {
this.id = id;
this.name = name;
this.email = email;
this.role = role;
}
}

// ─── With parameter properties (concise) ──────
class ConciseUser {
constructor(
public readonly id: string,
public name: string,
private email: string,
protected role: 'user' | 'admin'
) {}
// Typescript generates:
// - field declarations
// - this.id = id; this.name = name; etc. in the constructor

getEmail(): string {
return this.email; // private is accessible inside
}
}

// ─── Mixed: parameter props + regular assignment
class Mixed {
private token: string;

constructor(
public name: string, // param property
token: string // plain param — not a field
) {
this.token = token.trim(); // custom logic before assigning
}

hasToken(): boolean {
return this.token.length > 0;
}
}

const u = new ConciseUser('u_1', 'Alice', 'a@x.com', 'admin');
u.name = 'Alicia'; // ✅ public
// u.id = 'new'; // ❌ readonly

const m = new Mixed('Bob', ' tok123 ');

console.log(u.getEmail(), u.name);
console.log(m.hasToken());
// Parameter properties generate the field assignment in the constructor.
// The type annotations and modifiers themselves are erased.

class VerboseUser {
constructor(id, name, email, role) {
this.id = id;
this.name = name;
this.email = email;
this.role = role;
}
}

class ConciseUser {
constructor(id, name, email, role) {
this.id = id;
this.name = name;
this.email = email;
this.role = role;
}

getEmail() {
return this.email;
}
}

class Mixed {
constructor(name, token) {
this.name = name;
this.token = token.trim();
}

hasToken() {
return this.token.length > 0;
}
}

const u = new ConciseUser('u_1', 'Alice', 'a@x.com', 'admin');
u.name = 'Alicia';

const m = new Mixed('Bob', ' tok123 ');

console.log(u.getEmail(), u.name);
console.log(m.hasToken());
a@x.com Alicia
true

Modifier + param = field

constructor(public name: string) declares name as a public field and assigns it in the constructor. private, protected, and readonly work the same way.

Only in constructors

Parameter properties work only in the constructor — not in methods. They're a constructor-specific shorthand.

Mixed with plain params

You can mix parameter properties with regular constructor parameters. Regular params aren't fields — use them for transformation logic before assignment.

Readonly works too

public readonly id: string declares a public read-only field. Perfect for IDs, timestamps, and other immutable identity properties.

Use parameter properties by default — they're the modern idiom. The verbose form (declare field, assign in constructor) is legacy. Every TypeScript style guide recommends parameter properties for fields that are set directly from constructor arguments. Reserve the verbose form for cases where you need to transform the input before assignment. This single convention removes 4-6 lines from most classes and eliminates the "did I remember to assign this?" bug.

🤝 implements vs extends — contract vs inheritance

One inherits behavior. The other inherits obligations.

These two keywords look similar but do opposite things. extends inherits implementation from a parent class — you get its methods and fields for free. implements declares that a class satisfies an interface — you must provide the implementation yourself, or the compiler errors. A class can extend at most one class, but implement many interfaces.

implements.ts
// ─── implements: satisfy a contract ──────────
interface Shape {
area(): number;
perimeter(): number;
}

class Circle implements Shape {
constructor(public radius: number) {}
area() { return Math.PI * this.radius ** 2; }
perimeter() { return 2 * Math.PI * this.radius; }
}

class Square implements Shape {
constructor(public side: number) {}
area() { return this.side ** 2; }
perimeter() { return 4 * this.side; }
}

// ─── A class can implement many interfaces ────
interface Named { name: string; }
interface Serializable { serialize(): string; }

class Widget implements Named, Serializable {
constructor(public name: string) {}
serialize() { return JSON.stringify({ name: this.name }); }
}

// ─── extends: inherit implementation ─────────
class Animal {
constructor(public name: string) {}
speak(): string { return '...'; }
introduce(): string { return I'm ${this.name}`; }
}

class Dog extends Animal {
speak(): string { return 'Woof'; } // overrides
// introduce() inherited for free
}

// ─── Combining both ──────────────────────────
interface Serializable2 { serialize(): string; }

class User extends Animal implements Serializable2 {
constructor(name: string, public email: string) {
super(name);
}
serialize() { return JSON.stringify({ name: this.name, email: this.email }); }
}

const shapes: Shape[] = [new Circle(2), new Square(3)];
shapes.forEach(s => console.log(s.area().toFixed(2)));

const d = new Dog('Rex');
console.log(d.speak(), d.introduce());
// implements clauses erased entirely — no runtime effect.
// extends compiles to prototype chain (native JS class inheritance).

class Circle {
constructor(radius) { this.radius = radius; }
area() { return Math.PI * this.radius ** 2; }
perimeter() { return 2 * Math.PI * this.radius; }
}

class Square {
constructor(side) { this.side = side; }
area() { return this.side ** 2; }
perimeter() { return 4 * this.side; }
}

class Widget {
constructor(name) { this.name = name; }
serialize() { return JSON.stringify({ name: this.name }); }
}

class Animal {
constructor(name) { this.name = name; }
speak() { return '...'; }
introduce() { return I'm${this.name}`; }
}

class Dog extends Animal {
speak() { return 'Woof'; }
}

class User extends Animal {
constructor(name, email) {
super(name);
this.email = email;
}
serialize() { return JSON.stringify({ name: this.name, email: this.email }); }
}

const shapes = [new Circle(2), new Square(3)];
shapes.forEach(s => console.log(s.area().toFixed(2)));

const d = new Dog('Rex');
console.log(d.speak(), d.introduce());
12.57
9.00
Woof I'm Rex

implements = obligation

The class promises to satisfy the interface. If you forget a method, the compiler errors. No implementation is inherited — you write every method.

extends = inheritance

The class inherits methods and fields from a parent. You can override methods but you get defaults for free. Only one class parent is allowed.

Many interfaces, one class

A class can implement many interfaces: class User implements Named, Serializable, Timestamped. This is how TypeScript composes contracts without the diamond problem of multiple inheritance.

Combine both

class User extends Animal implements Serializable — inherit implementation from the parent, and satisfy an interface contract. This is the most common pattern in real codebases.

Prefer implements over extends in modern code. Inheritance is powerful but fragile — a small change to the parent class can break every subclass in unexpected ways. Interfaces are looser: a class satisfies the contract, period. When you need shared behavior, prefer composition (inject a helper object) over inheritance. Reserve extends for genuinely hierarchical domains (an HttpError hierarchy, an AbstractRepository) and framework requirements. In React code, this means: never extend React.Component — use function components. In backend code: prefer a service class that uses a repository over one that extends a base repository.

🎭 Abstract classes — templates for subclasses

Can't instantiate. Must extend. Provides shared behavior plus required overrides.

An abstract class sits between a full class and an interface. Like a full class, it can have method bodies and fields. Like an interface, it can declare abstract members that subclasses must implement. And crucially: you can't new it — it exists only to be extended.

abstract.ts
// ─── Abstract class ──────────────────────────
abstract class Shape {
constructor(public readonly name: string) {}

// Abstract methods: no body. Subclasses MUST implement.
abstract area(): number;
abstract perimeter(): number;

// Concrete method: shared by all subclasses.
describe(): string {
return ${this.name}: area=${this.area().toFixed(2)}, perimeter=${this.perimeter().toFixed(2)}`;
}
}

// ─── Subclasses implement abstract members ────
class Circle extends Shape {
constructor(public radius: number) { super('Circle'); }
area() { return Math.PI * this.radius ** 2; }
perimeter() { return 2 * Math.PI * this.radius; }
}

class Square extends Shape {
constructor(public side: number) { super('Square'); }
area() { return this.side ** 2; }
perimeter() { return 4 * this.side; }
}

// ─── Cannot instantiate the abstract class ───
// const s = new Shape('X'); // ❌ Cannot create an instance of an abstract class

// ─── But can use it as a type ────────────────
const shapes: Shape[] = [
new Circle(2),
new Square(3)
];

shapes.forEach(s => console.log(s.describe()));

// ─── Forgetting an abstract method errors ────
class Broken extends Shape {
constructor() { super('Broken'); }
area() { return 0; }
// ❌ Non-abstract class 'Broken' does not implement
// inherited abstract member 'perimeter'
}
// abstract keyword erased. Runtime is a normal class —
// but the abstract methods have no body so they'd be undefined.
// The TS compiler prevents instantiation, which is the only protection.

class Shape {
constructor(name) { this.name = name; }

describe() {
return ``${this.name}: area={this.perimeter().toFixed(2)}`;
}
}

class Circle extends Shape {
constructor(radius) { super('Circle'); this.radius = radius; }
area() { return Math.PI * this.radius ** 2; }
perimeter() { return 2 * Math.PI * this.radius; }
}

class Square extends Shape {
constructor(side) { super('Square'); this.side = side; }
area() { return this.side ** 2; }
perimeter() { return 4 * this.side; }
}

const shapes = [new Circle(2), new Square(3)];
shapes.forEach(s => console.log(s.describe()));

class Broken extends Shape {
constructor() { super('Broken'); }
area() { return 0; }
}
Circle: area=12.57, perimeter=12.57
Square: area=9.00, perimeter=12.00

Cannot instantiate

new Shape() fails at compile time. Abstract classes exist only to be extended — they're blueprints, not products.

Abstract members

abstract area(): number — no body. Subclasses must provide one. Forgetting is a compile error. This is how TypeScript enforces the template contract.

Concrete members inherited

describe() has a body and is inherited by all subclasses. Subclasses get free shared behavior while being forced to fill in the abstract gaps.

Type without a value

You can use Shape as a parameter type — function f(s: Shape) — even though you can't new Shape(). The type is valid; only the constructor is blocked.

Abstract classes are erased at runtime — there's no true protection. The abstract keyword is compile-time only. At runtime, Shape is a normal JavaScript class and new Shape('X') would technically succeed (though area() would be undefined). TypeScript prevents it at compile time; JavaScript doesn't. This is fine — the point is to catch the mistake before running, not to prevent runtime abuse. But know it: abstract, private, and protected are all compile-time constructs.

🎯 When to use a class (and when not to)

The most important class question. Get this right and classes are a tool; get it wrong and they're bloat.

TypeScript gives you two tools for describing data: interfaces (compile-time only, no runtime cost) and classes (runtime value, can be instantiated). Modern TypeScript code leans heavily toward interfaces for data and reserves classes for the few cases that genuinely need runtime identity.

✅ Use a class when…

…you need instanceof checks (custom errors, framework plugins). …you have state + behavior that travel together (a service with a connection). …a framework requires it (Angular, NestJS). …you need a subclass hierarchy with shared implementation (Error trees).

❌ Don't use a class when…

…you just need a data shape (use interface). …you need to group utilities (use functions or a module). …you're tempted to inherit for code reuse (use composition). …you're writing React UI (use functions).

The Error subclass case

Custom errors are the clearest justification: class NotFoundError extends Error {} lets you instanceof NotFoundError in a catch. Interfaces can't do this — they have no runtime value.

The React reality

Modern React has no classes. Function components + hooks. If you're building UI, you'll write zero classes in 2026. If you're building backend services, you'll write many. Know your domain.

The heuristic: "Does anything need to check instanceof?" If yes — class. If no — interface. Custom Error subclasses: yes (catch blocks check instanceof). Data transfer objects: no (just shapes). API responses: no. React components: no (functions). Backend services: yes (dependency injection uses class identity). Every class decision in a codebase can be made by asking this one question.

modern-errors.ts
// ─── A real use case: custom errors ──────────
class AppError extends Error {
constructor(message: string, public readonly code: string) {
super(message);
this.name = 'AppError';
}
}

class NotFoundError extends AppError {
constructor(resource: string) {
super(${resource} not found`, 'NOT_FOUND');
this.name = 'NotFoundError';
}
}

class ValidationError extends AppError {
constructor(public readonly fields: string[]) {
super('Validation failed', 'INVALID');
this.name = 'ValidationError';
}
}

// The instanceof narrowing works because classes are runtime values.
function handle(err: unknown): string {
if (err instanceof NotFoundError) {
return 404:${err.message}400: latex
{err.fields.join(', ')}``500:

{err.message}`;
}
return 'unknown';
}

// ─── The same problem with interfaces ─────────
// interface NotFoundError { message: string; code: 'NOT_FOUND' }
// → You'd check err.code === 'NOT_FOUND' (discriminated union)
// → Works, but requires the tag field. Classes give it for free
// via instanceof. Choose based on which is cleaner for your case.

🏆 Challenge

Three tasks. Each exercises a different class feature.

Task 1 — Build a typed stack

Implement a generic Stack class:

1. Private field items: T[]
2. push(item: T): void
3. pop(): T | undefined
4. peek(): T | undefined
5. get size(): number (getter)
6. isEmpty(): boolean

Instantiate with Stack and Stack. Hover over stack.pop() to verify the return type. Then try stack.push('x') on a Stack — the compile error is exactly why generics belong on classes. This is the kind of class that genuinely earns its runtime cost.

Task 2 — Error hierarchy

Build a custom error hierarchy:

1. Base AppError extends Error with a code: string and statusCode: number
2. Subclasses: NotFoundError (404), UnauthorizedError (401), ValidationError (400) with a fields: string[]
3. A function handleError(err: unknown): { status: number; message: string } that uses instanceof narrowing
4. Test with each error type and with a plain new Error()

Then convert the handleError function to use a discriminated union instead of classes. Which approach reads better? Which is easier to extend? The point: classes and discriminated unions solve overlapping problems — know both so you can pick consciously.

Task 3 — Abstract repository

Build an abstract Repository:

1. Abstract methods findById(id: string): Promise and save(item: T): Promise
2. Concrete method findOrThrow(id: string): Promise — calls findById, throws if null
3. Concrete method findAllOr(ids: string[]): Promise — maps over ids, filters nulls
4. Concrete InMemoryUserRepository extends Repository implementing both abstract methods using a Map
5. Test findOrThrow with a hit and a miss

The point: abstract classes shine when subclasses share significant behavior but must provide a few specific implementations. This is exactly how Repository patterns work in real backends — you'll see this structure in every NestJS, TypeORM, or Prisma codebase.

⚠️ Common mistakes

The class bugs that catch every beginner.

1. Thinking private is runtime-private

TypeScript's private is erased at runtime. obj['balance'] still works. Fix: use #balance (ES2022) for true runtime privacy, or accept that private is a compile-time contract.

2. Using extends for code reuse

Inheritance chains are fragile — a parent change ripples everywhere. Fix: composition (inject a helper). Inheritance is for genuine is-a relationships; most "reuse" isn't that.

3. Forgetting super()

A subclass constructor that extends a parent must call super() before accessing this. Fix: call super(...) first. The compiler will tell you — but the error is often confusing.

4. Declaring fields for every parameter

The verbose pattern (field declaration + constructor assignment) is boilerplate. Fix: parameter properties — constructor(public name: string). Cuts most classes in half.

5. Using classes for pure data

Data shapes don't need runtime identity. Fix: use interface. A class UserDTO with just fields and a constructor is an interface with extra steps.

6. Instantiating an abstract class

new AbstractClass() fails — that's the point. Fix: extend it and instantiate the subclass. Abstract classes are blueprints, not products.

7. Missing abstract methods in subclasses

A non-abstract subclass that doesn't implement all abstract methods fails to compile. Fix: implement every abstract member, or mark the subclass abstract too.

8. Overriding methods with incompatible types

A subclass method must be compatible with the parent's signature — same or narrower params, same or wider returns. Fix: match the parent's signature. If you need different behavior, the abstraction is wrong.

❓ Frequently asked questions

What are access modifiers in TypeScript?

Access modifiers control where a class member can be accessed. public (default) — anywhere. private — only inside the class. protected — inside the class and any subclass. TypeScript enforces these at compile time; they don't prevent access at runtime.

What is the difference between private and protected?

private members can only be accessed inside the class that defines them — subclasses cannot see them. protected members can be accessed by the class and any class that extends it. Use private when you never want subclasses to touch a member. Use protected when subclasses need to override or extend behavior.

What is the difference between extends and implements?

extends is class inheritance — a subclass inherits properties and methods from a parent class. implements is contract satisfaction — a class declares it will satisfy an interface's shape, but inherits nothing. A class can extend at most one class but implement many interfaces.

What is an abstract class?

An abstract class is a class that cannot be instantiated directly — it exists only to be extended. It can declare abstract members that subclasses must implement, and it can also provide concrete methods shared by all subclasses. Use abstract classes when subclasses share some implementation but must provide specific behavior.

What are parameter properties?

Parameter properties are a shorthand that combines a constructor parameter with a class field declaration. Instead of writing constructor(name: string) { this.name = name; } and declaring name: string separately, you write constructor(public name: string) {} — TypeScript handles both.

What is the difference between a class and an interface?

An interface describes a shape — it exists only at compile time and has no runtime value. A class is a runtime construct — it creates instances, has methods, can be instantiated with new. Use interfaces for data shapes and contracts. Use classes when you need runtime behavior, instanceof checks, or shared method implementations.

Are private members actually private at runtime?

No. TypeScript's private and protected are compile-time only — they're erased at build time. At runtime, you can still access private members via obj['privateProp']. For true runtime privacy, use JavaScript's #private fields (ES2022).

When should I use a class in TypeScript?

Use a class when you need runtime identity (instanceof), shared state with behavior (state machines, services with a lifecycle), or a framework convention that expects classes. For pure data shapes, use an interface or type alias. React code rarely uses classes; backend services usually do.

🔗 Continue your TypeScript path

🌐 External resources

🔧 Related problem solvers

← Back to TypeScript track
You're in the TypeScript track · Lesson 5