A Simple Guide to TypeScript Abstract Classes
You're building a payment processing system, and you've got three payment methods: credit cards, PayPal, and bank transfers. Each needs to process payments differently, but they all share common validation and logging. You start duplicating code across three separate classes, and soon you're fixing the same bug in three places. Sound familiar?
This is exactly where TypeScript abstract classes shine. They let you define shared behavior once while forcing each payment method to implement its specific processing logic. In this guide, we'll explore practical patterns for using abstract classes to build maintainable, type-safe code that doesn't repeat itself.
What Are Abstract Classes in TypeScript?
In TypeScript, abstract classes are defined with the abstract keyword. These classes can contain both abstract methods (without implementations) and concrete methods (with implementations). Here's a basic example:
abstract class PaymentProcessor {
// Abstract method - each payment type must implement this
abstract processPayment(amount: number): Promise<boolean>;
// Concrete method - shared across all payment types
logTransaction(amount: number): void {
console.log(`Processing payment of $${amount}`);
}
}
The PaymentProcessor class includes an abstract method processPayment(), which must be implemented by any class that extends it. This pattern enforces a contract that all subclasses must follow, making your code more reliable and type-safe. The logTransaction() method, on the other hand, is already implemented and can be used by all payment processors without duplication.
When working with Convex, understanding abstract classes helps you create better data models and API structures that enforce consistent behavior across your application.
Ensuring Method Implementation with Abstract Classes
Abstract classes can include methods without implementations, marked with the abstract keyword. Any class extending an abstract class must implement these methods:
abstract class DataRepository<T> {
// Abstract methods - each repository must define how to fetch and save
abstract fetchById(id: string): Promise<T | null>;
abstract save(data: T): Promise<void>;
// Concrete method - caching logic shared by all repositories
protected cache = new Map<string, T>();
async getCached(id: string): Promise<T | null> {
if (this.cache.has(id)) {
return this.cache.get(id)!;
}
const data = await this.fetchById(id);
if (data) {
this.cache.set(id, data);
}
return data;
}
}
class UserRepository extends DataRepository<User> {
async fetchById(id: string): Promise<User | null> {
// Fetch from your database
return await db.users.findById(id);
}
async save(user: User): Promise<void> {
await db.users.update(user);
this.cache.set(user.id, user); // Update cache
}
}
Here, the DataRepository class defines abstract methods fetchById() and save(), which UserRepository must implement. If a subclass fails to implement an abstract method, the TypeScript compiler will flag an error, catching the issue at compile time instead of runtime.
This approach is similar to how Convex helpers create reusable patterns for common operations while requiring specific implementations for each use case.
Extending Abstract Classes
To extend an abstract class, use the extends keyword. Subclasses inherit all properties and methods, and can add new ones or override existing ones. Here's an example:
abstract class HttpClient {
protected baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
// Abstract method - each client defines its auth strategy
abstract authenticate(): Promise<string>;
// Concrete method - all clients can use this
protected async request<T>(endpoint: string): Promise<T> {
const token = await this.authenticate();
const response = await fetch(`${this.baseUrl}${endpoint}`, {
headers: { Authorization: `Bearer ${token}` }
});
return response.json();
}
}
class ApiKeyClient extends HttpClient {
private apiKey: string;
constructor(baseUrl: string, apiKey: string) {
super(baseUrl); // Call parent constructor
this.apiKey = apiKey;
}
// Implement the abstract method
async authenticate(): Promise<string> {
return this.apiKey; // Simple API key auth
}
}
class OAuthClient extends HttpClient {
async authenticate(): Promise<string> {
// Complex OAuth flow
const token = await this.refreshAccessToken();
return token;
}
private async refreshAccessToken(): Promise<string> {
// OAuth refresh logic here
return "oauth_token";
}
}
Here, both ApiKeyClient and OAuthClient extend HttpClient and provide their own authenticate() methods. The inheritance pattern ensures that all HTTP clients have an authentication mechanism while allowing each to implement it differently.
This pattern helps you create consistent data access layers with proper type safety throughout your full-stack TypeScript application.
When to Choose Abstract Classes Over Interfaces
Both abstract classes and interfaces define blueprints for other classes, but knowing when to use each is crucial for maintainable code.
Key Differences
| Feature | Abstract Class | Interface |
|---|---|---|
| Implementation | Can include concrete methods with implementation | Only defines method signatures |
| Properties | Can have properties with default values | Only defines property types |
| Inheritance | Single inheritance only (extends one class) | Multiple implementation (implements many interfaces) |
| Runtime presence | Exists at runtime as a JavaScript class | Stripped away during compilation |
| Constructors | Can have constructor logic | No constructor support |
Decision Framework
Choose abstract classes when you need:
- Shared implementation logic across related classes
- Constructor initialization for common state
- Protected members that subclasses can access
- The Template Method pattern (more on this below)
Choose interfaces when you need:
- Pure type contracts without implementation
- Multiple inheritance capabilities
- To define object shapes for data structures
- Maximum flexibility for unrelated classes
Here's a practical example combining both:
// Interface defines the contract
interface Logger {
log(message: string, level: string): void;
}
// Abstract class provides shared implementation
abstract class BaseLogger implements Logger {
protected enabled: boolean = true;
// Concrete method - all loggers can use this
log(message: string, level: string): void {
if (!this.enabled) return;
const timestamp = new Date().toISOString();
const formatted = this.formatMessage(message, level, timestamp);
this.write(formatted);
}
// Abstract method - each logger decides where to write
protected abstract write(message: string): void;
// Concrete helper method
private formatMessage(message: string, level: string, timestamp: string): string {
return `[${timestamp}] ${level.toUpperCase()}: ${message}`;
}
}
class ConsoleLogger extends BaseLogger {
protected write(message: string): void {
console.log(message);
}
}
class FileLogger extends BaseLogger {
constructor(private filepath: string) {
super();
}
protected write(message: string): void {
// Write to file
fs.appendFileSync(this.filepath, message + '\n');
}
}
In this example, the Logger interface defines the contract, while BaseLogger provides shared formatting logic. Each concrete logger only needs to implement where to write the output. This combination gives you type safety, code reuse, and flexibility.
This approach aligns with Convex's schema validation practices, where you define type constraints while providing reusable implementation patterns.
The Template Method Pattern with Abstract Classes
One of the most powerful uses of TypeScript abstract classes is the Template Method pattern. This pattern lets you define the skeleton of an algorithm in the abstract class while allowing subclasses to implement specific steps.
Here's a real-world example with data processing:
abstract class DataProcessor<T> {
// Template method - defines the algorithm structure
async process(rawData: string): Promise<T[]> {
// Step 1: Validate (concrete method)
if (!this.validate(rawData)) {
throw new Error('Invalid data format');
}
// Step 2: Parse (abstract - subclass defines how)
const parsed = await this.parse(rawData);
// Step 3: Transform (abstract - subclass defines how)
const transformed = await this.transform(parsed);
// Step 4: Filter (concrete with hook)
return transformed.filter(item => this.shouldInclude(item));
}
// Concrete method with default implementation
protected validate(data: string): boolean {
return data.length > 0;
}
// Abstract methods - subclasses must implement
protected abstract parse(data: string): Promise<T[]>;
protected abstract transform(data: T[]): Promise<T[]>;
// Hook method - subclasses can override if needed
protected shouldInclude(item: T): boolean {
return true; // Include all by default
}
}
class CsvUserProcessor extends DataProcessor<User> {
protected async parse(data: string): Promise<User[]> {
// CSV-specific parsing
return data.split('\n').map(line => {
const [id, name, email] = line.split(',');
return { id, name, email };
});
}
protected async transform(users: User[]): Promise<User[]> {
// Normalize email addresses
return users.map(user => ({
...user,
email: user.email.toLowerCase().trim()
}));
}
protected shouldInclude(user: User): boolean {
// Only include users with valid emails
return user.email.includes('@');
}
}
class JsonUserProcessor extends DataProcessor<User> {
protected async parse(data: string): Promise<User[]> {
// JSON-specific parsing
return JSON.parse(data);
}
protected async transform(users: User[]): Promise<User[]> {
// Add default properties
return users.map(user => ({
id: user.id || crypto.randomUUID(),
name: user.name,
email: user.email
}));
}
}
The process() method defines the algorithm's structure: validate, parse, transform, and filter. Each processor implements parse() and transform() differently, but they all follow the same overall flow. This pattern prevents code duplication while maintaining flexibility.
This pattern is similar to how Convex custom functions let you define implementation requirements while providing shared functionality.
Constructors in Abstract Classes
Abstract classes can have constructor methods to initialize shared properties. This is especially useful for setting up dependencies or configuration that all subclasses need:
abstract class CacheStore<T> {
protected cache: Map<string, T>;
protected maxSize: number;
constructor(maxSize: number = 100) {
this.cache = new Map();
this.maxSize = maxSize;
}
// Abstract method - each store defines how to fetch
abstract fetch(key: string): Promise<T>;
async get(key: string): Promise<T> {
// Check cache first
if (this.cache.has(key)) {
return this.cache.get(key)!;
}
// Fetch and cache
const value = await this.fetch(key);
this.set(key, value);
return value;
}
protected set(key: string, value: T): void {
// Evict oldest entry if cache is full
if (this.cache.size >= this.maxSize) {
const firstKey = this.cache.keys().next().value;
this.cache.delete(firstKey);
}
this.cache.set(key, value);
}
}
class DatabaseCache extends CacheStore<User> {
private db: Database;
constructor(db: Database, maxSize?: number) {
super(maxSize); // Call parent constructor
this.db = db;
}
async fetch(key: string): Promise<User> {
return await this.db.users.findById(key);
}
}
class ApiCache extends CacheStore<ApiResponse> {
constructor(private endpoint: string) {
super(50); // Custom cache size
}
async fetch(key: string): Promise<ApiResponse> {
const response = await fetch(`${this.endpoint}/${key}`);
return response.json();
}
}
Each subclass calls super() to initialize the shared cache infrastructure, then adds its own specific setup. The abstract class handles cache management, while subclasses only need to define how to fetch data.
When working with Convex schemas, this pattern helps you create consistent data validation and initialization across related data types.
Building a Class Hierarchy
Abstract classes can create multi-level hierarchies where each level adds more specific behavior. This is useful for modeling real-world systems with layers of specialization:
abstract class Component {
protected id: string;
constructor(id: string) {
this.id = id;
}
// All components can be mounted and unmounted
abstract mount(): void;
abstract unmount(): void;
}
abstract class InteractiveComponent extends Component {
protected listeners: Map<string, Function[]> = new Map();
// Interactive components must handle events
abstract handleClick(event: MouseEvent): void;
// Concrete event system all interactive components share
addEventListener(event: string, callback: Function): void {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event)!.push(callback);
}
protected emit(event: string, data: any): void {
this.listeners.get(event)?.forEach(cb => cb(data));
}
}
class Button extends InteractiveComponent {
private label: string;
constructor(id: string, label: string) {
super(id);
this.label = label;
}
mount(): void {
console.log(`Mounting button: ${this.label}`);
// Attach to DOM
}
unmount(): void {
console.log(`Unmounting button: ${this.label}`);
this.listeners.clear();
}
handleClick(event: MouseEvent): void {
console.log(`Button ${this.id} clicked`);
this.emit('click', { id: this.id, label: this.label });
}
}
class Form extends InteractiveComponent {
private fields: InteractiveComponent[] = [];
mount(): void {
console.log('Mounting form');
this.fields.forEach(field => field.mount());
}
unmount(): void {
this.fields.forEach(field => field.unmount());
this.listeners.clear();
}
handleClick(event: MouseEvent): void {
// Forms might handle clicks differently
console.log('Form area clicked');
}
addField(field: InteractiveComponent): void {
this.fields.push(field);
}
}
In this hierarchy, Button and Form extend InteractiveComponent, which extends Component. Each level adds more specific behavior: Component defines the basic lifecycle, InteractiveComponent adds event handling, and the concrete classes implement their specific mounting and interaction logic.
This hierarchical approach resembles how Convex API generation creates structured access to your backend functions with full type safety.
Problems You Might Encounter
When working with abstract classes in TypeScript, here are the most common issues you'll face and how to solve them:
Missing Abstract Method Implementations
The most common error occurs when you forget to implement an abstract method:
abstract class EventHandler {
abstract handle(event: Event): void;
}
// Error: Non-abstract class 'ClickHandler' does not implement
// inherited abstract member 'handle' from class 'EventHandler'
class ClickHandler extends EventHandler {
// Forgot to implement handle()
}
Fix: TypeScript will tell you exactly which methods you're missing. Implement them all:
class ClickHandler extends EventHandler {
handle(event: Event): void {
console.log('Click handled');
}
}
Trying to Instantiate Abstract Classes
You can't create instances of abstract classes directly:
abstract class Database {
abstract connect(): void;
}
// Error: Cannot create an instance of an abstract class
const db = new Database();
Fix: Only instantiate concrete subclasses:
class PostgresDB extends Database {
connect(): void {
console.log('Connected to Postgres');
}
}
const db = new PostgresDB(); // This works
Forgetting to Call super() in Constructors
When your abstract class has a constructor, you must call super() in subclass constructors before accessing this:
abstract class Service {
protected apiKey: string;
constructor(apiKey: string) {
this.apiKey = apiKey;
}
}
class EmailService extends Service {
constructor(apiKey: string, private fromAddress: string) {
// Error: 'super' must be called before accessing 'this'
this.fromAddress = fromAddress;
super(apiKey);
}
}
Fix: Always call super() first:
class EmailService extends Service {
constructor(apiKey: string, private fromAddress: string) {
super(apiKey); // Call super first
this.fromAddress = fromAddress; // Now this is safe
}
}
Method Signature Mismatches
Your implementation must exactly match the abstract method signature:
abstract class Validator {
abstract validate(data: string): boolean;
}
// Error: Property 'validate' in type 'EmailValidator' is not
// assignable to the same property in base type 'Validator'
class EmailValidator extends Validator {
validate(data: string, strict?: boolean): boolean {
// Wrong signature - added a parameter
return data.includes('@');
}
}
Fix: Match the signature exactly. If you need extra parameters, make them optional in the abstract class:
abstract class Validator {
abstract validate(data: string, strict?: boolean): boolean;
}
class EmailValidator extends Validator {
validate(data: string, strict = false): boolean {
return strict
? /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data)
: data.includes('@');
}
}
Managing Complex Hierarchies
With multiple levels of abstract classes, it's easy to lose track of what needs implementation:
abstract class A {
abstract methodA(): void;
}
abstract class B extends A {
abstract methodB(): void;
// Still abstract - doesn't implement methodA
}
// Must implement both methodA and methodB
class C extends B {
methodA(): void {
console.log('A');
}
methodB(): void {
console.log('B');
}
}
Tip: Use your IDE's autocomplete to see all unimplemented members. In VS Code, when you type extends, you'll get a quick fix option to implement all abstract methods.
Final Thoughts about Abstract Classes
Abstract classes in TypeScript give you a powerful middle ground between interfaces and concrete classes. They let you share implementation code while still enforcing type-safe contracts that subclasses must follow.
Here's when to reach for them:
- You're writing similar classes that share behavior but differ in specific implementations
- You need the Template Method pattern to define algorithm structure
- You want constructor logic that all subclasses should inherit
- You're building a plugin system or framework where you control the high-level flow
Start with interfaces for simple contracts, then refactor to abstract classes when you notice code duplication across implementations. Your future self will thank you when you're not fixing the same bug in five different classes.