TypeScript Classes and Objects: Practice Questions
July 25, 2025
Now that we've explored TypeScript's class features in our previous article, let's put that knowledge to work. Classes in TypeScript aren't just JavaScript classes with types sprinkled on top. They give us powerful tools like the implements
keyword, access modifiers, and abstract classes that completely change how we build reliable object-oriented applications.
These practice questions will help you understand when and how to use these features effectively. We've designed them to mirror the kinds of challenges you'll face when building real applications, starting with basic concepts and building up to more complex architectural decisions.
Question 1: Implementing Interface Contracts (Easy)
You're building a notification system for a project management app. You need to ensure that all notification types can be sent and logged consistently.
Given this interface:
interface Notifiable {
send(): boolean;
getRecipient(): string;
}
Create an EmailNotification
class that implements this interface. The class should:
- Have a constructor that takes
recipient
(string) andsubject
(string) parameters - Store these as private properties
- Implement the required methods appropriately
Try to solve this before looking at the explanation below.
Answer and Explanation
Let's start with the immediate problem: we need a class that guarantees it can be used anywhere a Notifiable
object is expected. This is exactly what the implements
keyword gives us.
interface Notifiable {
send(): boolean;
getRecipient(): string;
}
class EmailNotification implements Notifiable {
private recipient: string;
private subject: string;
constructor(recipient: string, subject: string) {
this.recipient = recipient;
this.subject = subject;
}
send(): boolean {
console.log(`Sending email to ${this.recipient}: ${this.subject}`);
return true; // Simulate successful send
}
getRecipient(): string {
return this.recipient;
}
}
Notice what happens here when we use implements Notifiable
. TypeScript creates a contract between our interface and class. If we forget to implement send()
or getRecipient()
, we get immediate compile-time errors. We can't even build our code until we fix it.
This is hugely better than finding out at runtime that our notification object is missing methods. Instead of getting a production error that says "Cannot read property 'send' of undefined," we catch the problem while writing code.
The private properties ensure that recipient
and subject
can only be accessed through our controlled methods. This prevents code from accidentally modifying email details after the notification is created.
Why does this pattern matter? When you're building a notification system, you might have EmailNotification
, SlackNotification
, PushNotification
classes. They all implement the same Notifiable
interface, so your notification-sending code doesn't care which specific type it's working with. It just knows the interface will be there.
Question 2: Managing Access with Modifiers (Easy-Medium)
You're building a user account system where some properties should be readable but not changeable, others should be completely private, and some should be accessible to subclasses but not external code.
Create a UserAccount
class with the following requirements:
userId
should be readable but never changeable after creationpasswordHash
should be completely private to the classlastLoginDate
should be accessible to subclasses but not external code- Include methods to authenticate users and update login time
Try implementing this before checking the solution.
Answer and Explanation
Let's think about this step by step. We need different levels of access control for different properties. This is where TypeScript's access modifiers really shine.
class UserAccount {
public readonly userId: string;
private passwordHash: string;
protected lastLoginDate: Date | null = null;
constructor(userId: string, password: string) {
this.userId = userId;
this.passwordHash = this.hashPassword(password);
}
private hashPassword(password: string): string {
// Simple hash simulation
return `hashed_${password}`;
}
authenticate(password: string): boolean {
const hashedInput = this.hashPassword(password);
if (hashedInput === this.passwordHash) {
this.updateLoginTime();
return true;
}
return false;
}
private updateLoginTime(): void {
this.lastLoginDate = new Date();
}
getLastLogin(): Date | null {
return this.lastLoginDate;
}
}
Notice how each access modifier serves a specific purpose:
public readonly userId
means anyone can read the user ID, but nobody can change it after the object is created. This is perfect for identifiers that need to be stable but visible.
private passwordHash
ensures that password data is completely inaccessible from outside the class. Even if someone inspects the object, they can't get to the password hash. Only methods inside this class can work with it.
protected lastLoginDate
creates a middle ground. External code can't touch it directly, but if we create a subclass like AdminAccount
, that subclass can access and modify login dates. This gives us controlled inheritance without exposing internals to everyone.
Here's what happens when we try to break these rules:
const user = new UserAccount("user123", "mypassword");
console.log(user.userId); // ✅ Works - it's public readonly
user.userId = "newid"; // ❌ Error: Cannot assign to 'userId' because it's readonly
user.passwordHash; // ❌ Error: Property 'passwordHash' is private
user.lastLoginDate; // ❌ Error: Property 'lastLoginDate' is protected
user.authenticate("mypassword"); // ✅ Works - public method
console.log(user.getLastLogin()); // ✅ Works - controlled access to protected data
The key insight is that TypeScript catches these access violations at compile time. Your editor lights up with red error messages immediately. You can't even build your application until you fix the access violations. This prevents entire categories of bugs where code accidentally modifies object internals it shouldn't touch.
Question 3 (Medium): Abstract Classes for Shared Behavior
You need a base class for different notification types. All notifications have a message and timestamp, but each type formats differently:
// We need classes for email and SMS notifications
// Both share: message, timestamp, markAsRead()
// Each implements differently: format() and send()
const emailNotification = new EmailNotification("Welcome to our app!");
const smsNotification = new SMSNotification("Your code is 1234");
console.log(emailNotification.format()); // Should include HTML
console.log(smsNotification.format()); // Should be plain text
Your task: Create an abstract Notification
class with concrete EmailNotification
and SMSNotification
subclasses.
Answer and Explanation
Let's think about this step by step. We need shared behavior (message, timestamp, markAsRead) but different implementations (format differently). This is exactly what abstract classes solve.
abstract class Notification {
protected message: string;
protected timestamp: Date;
protected isRead: boolean = false;
constructor(message: string) {
this.message = message;
this.timestamp = new Date();
}
// Concrete method - all notifications can use this
markAsRead(): void {
this.isRead = true;
}
getTimestamp(): Date {
return this.timestamp;
}
// Abstract methods - must be implemented by subclasses
abstract format(): string;
abstract send(): boolean;
}
class EmailNotification extends Notification {
format(): string {
return `<html><body><h3>${
this.message
}</h3><p>Sent: ${this.timestamp.toISOString()}</p></body></html>`;
}
send(): boolean {
console.log(`Sending email: ${this.format()}`);
return true;
}
}
class SMSNotification extends Notification {
format(): string {
return `${this.message} (${this.timestamp.toLocaleTimeString()})`;
}
send(): boolean {
console.log(`Sending SMS: ${this.format()}`);
return true;
}
}
// Now we can work with any notification type
const notifications: Notification[] = [
new EmailNotification("Welcome to our app!"),
new SMSNotification("Your code is 1234"),
];
notifications.forEach((notification) => {
console.log(notification.format());
notification.send();
notification.markAsRead();
});
Notice what happens here: the abstract class provides a foundation with shared properties and methods, but forces subclasses to implement type-specific behavior. TypeScript won't let you create a class that extends Notification
without implementing both format()
and send()
.
This design is powerful because you can add new notification types (like PushNotification
) by just extending the abstract class and implementing the required methods. The rest of your code works with any notification type through the shared Notification
interface.