TypeScript Classes and Objects: Object-Oriented Programming with Types
July 25, 2025
In our previous article, we explored how interfaces describe the shape of our data. They're excellent at defining contracts for what objects should look like. But there's something important they can't do.
Let's say we're building a tutorial system for ExplainThis. We might start with this interface:
interface Tutorial {
title: string;
isPublished: boolean;
}
Looks good. But then we realize we need tutorials that can actually do things. Publish themselves. Generate reading time estimates. Validate their own content.
With just interfaces, we'd end up writing separate functions for everything:
function publishTutorial(tutorial: Tutorial) {
/* ... */
}
function calculateReadingTime(tutorial: Tutorial) {
/* ... */
}
function validateTutorialContent(tutorial: Tutorial) {
/* ... */
}
This works, but it's annoying. Our tutorial data lives in one place, and all the tutorial behavior lives somewhere else. We have to remember which functions go with which objects. When we see a Tutorial
object in our code, there's no indication that publishTutorial()
and calculateReadingTime()
functions even exist for it.
Classes solve this by bundling the data and behavior together. When we create a tutorial object from a class, it comes with its behavior built in. We don't have to hunt around for the right functions.
But there's something important to understand: TypeScript classes aren't just JavaScript classes with some type annotations sprinkled on top. JavaScript has had classes since ES6, but they're pretty basic. We can create objects, add methods, extend other classes. That's about it.
TypeScript adds three features that JavaScript doesn't have at all. And these features completely change how we build applications. They give us compile-time guarantees that our objects will actually work the way we expect them to.
implements
: Guaranteeing Your Classes Do What Their Interfaces Promise
Think about a problem we've probably hit before. We define an interface that says "hey, objects that can be saved should have a save()
method":
interface Saveable {
save(): void;
}
Then we write a class:
class Tutorial {
content: string = "";
// Oops, forgot to add save()!
}
TypeScript won't complain. Our interface exists, our class exists, but there's no connection between them. The interface is just sitting there, describing what a saveable thing should look like. The class is doing its own thing. They're not talking to each other.
We only find out something's wrong when we try to call save()
on a tutorial and get a runtime error. By then, our code is already running in production, and someone's tutorial content might not get saved.
Enter the implements
keyword. It creates a contract between our interface and our class. It forces our class to actually provide everything the interface promises:
interface Saveable {
save(): void;
isDirty(): boolean;
}
class Tutorial implements Saveable {
private content: string = "";
private modified: boolean = false;
save(): void {
this.modified = false;
}
isDirty(): boolean {
return this.modified;
}
}
Now if we forget to add save()
or isDirty()
, TypeScript yells at us immediately. We'll get a red squiggly line in our editor that says something like "Class 'Tutorial' incorrectly implements interface 'Saveable'. Property 'save' is missing." We can't even compile our code until we fix it.
What a massive improvement over finding out at runtime that our object is missing methods it's supposed to have. Instead of getting a production bug report that says "Cannot read property 'save' of undefined," we catch the problem while we're writing the code.
The real power becomes clear when we have multiple different classes that all implement the same interface. Think about it: there might be different types of content in ExplainThis. Tutorials, blog posts, code examples. They all need to be publishable, but they publish in completely different ways:
interface Publishable {
publish(): boolean;
getSlug(): string;
}
class Tutorial implements Publishable {
publish(): boolean {
return true;
}
getSlug(): string {
/* tutorial-specific slug */ return "";
}
}
class BlogPost implements Publishable {
publish(): boolean {
return true;
}
getSlug(): string {
/* blog-specific slug */ return "";
}
}
In the example above, both classes are guaranteed to have publish()
and getSlug()
methods. So code that expects a Publishable
object doesn't care if it gets a Tutorial
or BlogPost
. It just knows the interface will be there.
That's the magic of implements
. Our interfaces say what something should be able to do. Our classes actually do those things. And implements
makes sure they match up. Without implements
, we'd have no guarantee that a class actually provides the methods its interface promises.
private
, protected
, public
: Controlling Access to Your Class Internals
JavaScript has had a privacy problem for years. Until recently, everything in a class was public. Anyone could mess with anything.
class BankAccount {
balance = 0;
deposit(amount) {
this.balance += amount;
}
}
const account = new BankAccount();
account.balance = 1000000; // Uh oh
That's not great. We want people to use deposit()
, not directly change the balance.
Modern JavaScript (ES2022+) now has private fields using #
syntax:
class BankAccount {
#balance = 0; // Private field
deposit(amount) {
this.#balance += amount;
}
getBalance() {
return this.#balance;
}
}
const account = new BankAccount();
account.#balance = 1000000; // SyntaxError at runtime
But JavaScript's privacy is limited. TypeScript goes much further with access modifiers: private
, protected
, public
, and readonly
. These provide compile-time protection and more flexibility.
class BankAccount {
private balance: number = 0;
public readonly owner: string;
protected accountNumber: string;
constructor(owner: string, accountNumber: string) {
this.owner = owner;
this.accountNumber = accountNumber;
}
deposit(amount: number): void {
this.balance += amount;
}
getBalance(): number {
return this.balance;
}
}
Let's break down what each modifier does and how they differ from JavaScript's #
fields:
private
means only this class can touch it. Nobody else can accessbalance
directly. They have to usedeposit()
andgetBalance()
. This stops people from accidentally setting the balance to something invalid. Unlike JavaScript's#balance
, TypeScript'sprivate
is checked at compile time.public
(the default) means anyone can access it.deposit()
is public because that's part of what a bank account should do.readonly
means we can read it but not change it after creation. We can checkaccount.owner
but we can't change it to someone else. JavaScript's#
fields don't have this concept.protected
means this class and its subclasses can access it, but nobody else. Maybe specialized account types need the account number, but regular code shouldn't touch it. This is more flexible than JavaScript's private fields, which can't be accessed by subclasses at all.
The key difference is that TypeScript's modifiers are compile-time protections. TypeScript stops us from writing code that breaks these rules before our code even runs. JavaScript's #
fields throw runtime errors.
So what does this mean? If you try to access private properties in TypeScript, your editor lights up with red error messages immediately. You can't even compile your code. With JavaScript's #
fields, your code compiles fine, but crashes when it actually runs:
const account = new BankAccount("Sarah", "123456");
account.deposit(100);
console.log(account.getBalance()); // ✅ 100
console.log(account.owner); // ✅ "Sarah"
// These are prevented by TypeScript:
account.balance = 500; // ❌ Error: Property 'balance' is private
account.owner = "Bob"; // ❌ Error: Cannot assign to 'owner' because it's readonly
account.accountNumber; // ❌ Error: Property 'accountNumber' is protected
This compile-time checking means we catch privacy violations while writing code, not when our users are trying to use our application. We get bulletproof encapsulation. Our objects control their own internal state completely. And we find out immediately if code tries to break that control.
abstract
: Classes That Force Implementation
Here's a frustrating problem. Let's say we're building ExplainThis's content system. We have tutorials, blog posts, code examples. They all share some stuff: titles, publish dates, the ability to be published or unpublished.
So we create a base Content
class:
class Content {
protected title: string;
protected publishDate: Date;
protected isPublished: boolean = false;
constructor(title: string) {
this.title = title;
this.publishDate = new Date();
}
publish(): void {
this.isPublished = true;
}
// But what do we return here?
getDuration(): number {
return ???; // Tutorials have reading time, code examples have study time
}
// And here?
getDisplayInfo(): string {
return ???; // Each content type displays differently
}
}
We're stuck. The base class needs getDuration()
and getDisplayInfo()
methods because all content types use them. But what would the base implementation return? A tutorial's duration is reading time based on word count. A code example's duration is study time based on complexity. There's no meaningful base implementation.
Our options are all bad:
- Return dummy values like
0
(misleading) - Throw runtime errors (crashes our app)
- Leave the methods empty (breaks everything that calls them)
And worse, someone could accidentally create a plain Content
object:
const content = new Content("Some Title");
console.log(content.getDuration()); // What happens here?
Abstract classes solve this perfectly. They let us define shared structure and behavior, but prevent creating instances of incomplete base classes:
abstract class Content {
protected title: string;
protected publishDate: Date;
protected isPublished: boolean = false;
constructor(title: string) {
this.title = title;
this.publishDate = new Date();
}
publish(): void {
this.isPublished = true;
}
abstract getDisplayInfo(): string; // Must be implemented by subclasses
abstract getDuration(): number; // Must be implemented by subclasses
}
The abstract
keyword does two things:
We can't create instances of abstract classes. Trying to do
new Content("Title")
gives us a compile error.Abstract methods must be implemented by subclasses. If a class extends this abstract class but forgets to implement
getDisplayInfo()
, TypeScript won't let us compile.
Now we can create actual implementations:
class Tutorial extends Content {
private wordCount: number;
constructor(title: string, wordCount: number) {
super(title);
this.wordCount = wordCount;
}
getDisplayInfo(): string {
return `Tutorial: ${this.title}`;
}
getDuration(): number {
return Math.ceil(this.wordCount / 200); // Reading time in minutes
}
}
class CodeExample extends Content {
private lineCount: number;
constructor(title: string, lineCount: number) {
super(title);
this.lineCount = lineCount;
}
getDisplayInfo(): string {
return `Code Example: ${this.title}`;
}
getDuration(): number {
return Math.ceil(this.lineCount / 10); // Study time in minutes
}
}
Abstract classes give us something regular inheritance can't: they guarantee certain methods exist on all subclasses, but we can't create the abstract class itself. TypeScript makes sure Tutorial
and CodeExample
both implement getDisplayInfo()
and getDuration()
. If they don't, we get compile errors.
This creates a really useful pattern. We can write code that works with any Content
type, and we know certain methods will always be there:
function displayContent(content: Content): void {
console.log(content.getDisplayInfo());
console.log(`Duration: ${content.getDuration()} minutes`);
}
const tutorial = new Tutorial("TypeScript Classes Guide", 1000);
const codeExample = new CodeExample("Interface Implementation", 50);
displayContent(tutorial); // Works with Tutorial
displayContent(codeExample); // Works with CodeExample
displayContent(new Content("Title")); // ❌ Error: Cannot create abstract class
Summary
TypeScript classes aren't just JavaScript classes with some types thrown on top. TypeScript adds key features that JavaScript doesn't have at all.
The implements
keyword connects our interfaces or types to our classes. Interfaces say what objects should be able to do. implements
makes sure our classes actually do those things. We catch missing methods at compile time instead of runtime.
Access modifiers let us control what parts of our objects are accessible. private
, protected
, public
, and readonly
create compile-time boundaries around our object's internal state. JavaScript can't do this.
Abstract classes let us define shared structure and behavior, but prevent creation of overly generic base objects. They guarantee that subclasses implement required methods while making sure we can't create instances of incomplete base classes.
These features work together to give us a type-safe object-oriented programming experience that goes way beyond what JavaScript can provide.
Start looking for places where we need guaranteed contracts (implements
), controlled access to internals (access modifiers), or shared structure with enforced specialization (abstract classes). That's where TypeScript classes really shine.