Skip to main content

TypeScript Classes: A Practical Guide

Your user object is being created in a dozen places. Some spots include role, some don't. Some set createdAt, some forget to. There's no single source of truth for what a valid User looks like, and refactoring means hunting down every creation site.

Classes fix this at the source. Define the shape, the constructor, and the access rules once, and every instance follows the same contract — enforced by TypeScript at compile time.

Here's a simple Person class:

class Person {
name: string;
age: number;

constructor(name: string, age: number) {
this.name = name;
this.age = age;
}

greet() {
console.log(`Hello, my name is ${this.name} and I am ${this.age} years old.`);
}
}

This covers the three core pieces: properties (name and age), a constructor that initializes them, and a method (greet) that acts on them.

As your project grows, classes help you create consistent objects and organize code logically.

Inheritance in TypeScript

Inheritance lets you build on existing classes, keeping the parent's features while adding your own. Use the extends keyword to set up the relationship.

Here's an Animal class and a Dog class that extends it:

class Animal {
name: string;

constructor(name: string) {
this.name = name;
}

eat() {
console.log(`${this.name} is eating.`);
}
}

class Dog extends Animal {
breed: string;

constructor(name: string, breed: string) {
super(name); // Call the parent constructor
this.breed = breed;
}

bark() {
console.log(`${this.name} the ${this.breed} says Woof!`);
}
}

const rex = new Dog("Rex", "German Shepherd");
rex.eat(); // Inherited method: "Rex is eating."
rex.bark(); // Own method: "Rex the German Shepherd says Woof!"

The Dog class:

  • Inherits name and eat() from Animal
  • Adds its own breed property and bark() method
  • Uses super() to call the parent constructor

Inheritance follows the "is-a" relationship: a dog is an animal, so extending Animal makes sense. For more on how TypeScript handles the extends keyword beyond classes, see the guide on TypeScript extends.

Getters and Setters

Getters and setters let you control how a class property is accessed and modified. They look like plain properties from the outside, but they run custom logic under the hood.

A getter uses the get keyword. A setter uses set. You'll typically pair them with a private backing property:

class UserProfile {
private _email: string;

constructor(email: string) {
this._email = email;
}

get email(): string {
return this._email;
}

set email(value: string) {
if (!value.includes("@")) {
throw new Error("Invalid email address");
}
this._email = value;
}
}

const user = new UserProfile("alice@example.com");
console.log(user.email); // "alice@example.com" — calls the getter
user.email = "bob@example.com"; // calls the setter, validation passes
user.email = "not-an-email"; // throws Error: "Invalid email address"

The caller uses user.email like a normal property. They don't need to know validation is happening. You can add or change that validation later without touching any code that reads or writes user.email.

A few things worth keeping in mind:

  • Private backing properties are typically prefixed with _ by convention to avoid name conflicts
  • A getter with no setter makes the property effectively read-only
  • Keep getters lightweight — avoid heavy computation or side effects inside them

Managing State and Behavior

Classes manage data (state) through properties and actions (behavior) through methods. Keeping related functionality together makes code easier to reason about.

Here's a Counter class:

class Counter {
private count: number;

constructor() {
this.count = 0;
}

increment() {
this.count++;
}

decrement() {
if (this.count > 0) {
this.count--;
}
}

reset() {
this.count = 0;
}

getCount() {
return this.count;
}
}

const counter = new Counter();
counter.increment();
counter.increment();
console.log(counter.getCount()); // 2
counter.reset();
console.log(counter.getCount()); // 0

By marking count as private, nothing outside the class can manipulate it directly. The only way to change it is through increment(), decrement(), or reset(). That constraint makes bugs much easier to track down.

Using Access Modifiers

Access modifiers control the visibility of class members. TypeScript supports private, protected, and public. Without a modifier, members default to public.

class BankAccount {
private balance: number; // Only accessible within this class
protected accountNumber: string; // Accessible within this class and subclasses
public owner: string; // Accessible anywhere

constructor(owner: string, accountNumber: string, initialBalance: number) {
this.owner = owner;
this.accountNumber = accountNumber;
this.balance = initialBalance;
}

public deposit(amount: number) {
if (amount > 0) {
this.balance += amount;
return true;
}
return false;
}

public withdraw(amount: number) {
if (amount > 0 && this.balance >= amount) {
this.balance -= amount;
return true;
}
return false;
}

public getBalance() {
return this.balance;
}
}

const account = new BankAccount("John Doe", "12345", 1000);
account.deposit(500);
console.log(account.getBalance()); // 1500
console.log(account.owner); // "John Doe"
// console.log(account.balance); // Error — private member not accessible

How to think about each modifier:

  • private: only accessible inside the class itself
  • protected: accessible in the class and any subclasses that extend it
  • public: accessible from anywhere

Getting these right matters more than it might seem. The TypeScript best practices guide covers how to apply them in production code.

Static Methods and Properties

Static members belong to the class itself, not to any instance. They're useful for utility functions, constants, or factory methods that don't depend on instance state.

class MathUtils {
static readonly PI = 3.14159265359;

static add(a: number, b: number) {
return a + b;
}

static multiply(a: number, b: number) {
return a * b;
}

static square(x: number) {
return x * x;
}

static calculateCircleArea(radius: number) {
return MathUtils.PI * MathUtils.square(radius);
}
}

console.log(MathUtils.PI); // 3.14159265359
console.log(MathUtils.add(5, 3)); // 8
console.log(MathUtils.calculateCircleArea(2)); // ~12.566

Key points about static members:

  • Call them on the class itself: MathUtils.add(), not new MathUtils().add()
  • They can't access instance properties via this
  • They can call other static members using the class name
  • Use static readonly for constants that shouldn't change

Static members work well for functionality that conceptually belongs to the class but doesn't depend on instance state, like factory methods or utility functions. This pattern shows up often when working with Convex where utility functions help manage data operations.

Implementing Interfaces

Interfaces define a contract a class must fulfill. They list what properties and methods must exist, without providing any implementation.

interface Printable {
content: string;
print(): void;
getFormattedContent(): string;
}

class Document implements Printable {
content: string;
private author: string;

constructor(content: string, author: string) {
this.content = content;
this.author = author;
}

print() {
console.log(this.getFormattedContent());
}

getFormattedContent(): string {
return `Document by ${this.author}:\n${this.content}`;
}

// Classes can have additional methods beyond the interface
getAuthor(): string {
return this.author;
}
}

class Receipt implements Printable {
content: string;
private total: number;

constructor(content: string, total: number) {
this.content = content;
this.total = total;
}

print() {
console.log(this.getFormattedContent());
}

getFormattedContent(): string {
return `RECEIPT\n${this.content}\nTotal: $${this.total.toFixed(2)}`;
}
}

function printDocument(doc: Printable) {
doc.print();
}

const myDoc = new Document("TypeScript interfaces are useful", "Developer");
const myReceipt = new Receipt("Coffee: $3.99", 3.99);

printDocument(myDoc); // Works with Document
printDocument(myReceipt); // Works with Receipt too

Both Document and Receipt implement Printable, so printDocument works with either one. You're writing code against the contract, not the concrete type.

The interface pattern is particularly useful when:

  • Multiple classes need to follow the same contract
  • You want to swap implementations without changing calling code
  • You're defining pluggable components that can be replaced

This approach is similar to argument validation techniques in Convex, where schema enforcement ensures data consistency. You can also use interfaces to define functional relationships between components.

Abstract Classes in TypeScript

An abstract class can't be instantiated directly, but unlike an interface, it can hold real method implementations. It's the right tool when you need a base type that enforces a contract AND shares some actual code.

You use abstract classes when you want to share some behavior across subclasses while requiring each subclass to implement certain parts on its own:

abstract class Shape {
abstract getArea(): number; // Subclasses must implement this

// Shared implementation available to all subclasses
describe() {
console.log(`This shape has an area of ${this.getArea()}`);
}
}

class Circle extends Shape {
constructor(private radius: number) {
super();
}

getArea(): number {
return Math.PI * this.radius ** 2;
}
}

class Rectangle extends Shape {
constructor(private width: number, private height: number) {
super();
}

getArea(): number {
return this.width * this.height;
}
}

const circle = new Circle(5);
const rect = new Rectangle(4, 6);

circle.describe(); // "This shape has an area of 78.54..."
rect.describe(); // "This shape has an area of 24"

Shape provides describe() for free, but forces every subclass to define its own getArea(). If a subclass forgets, TypeScript catches it at compile time.

For a deeper dive into when to use abstract classes and how they compare to interfaces, see the abstract classes guide.

TypeScript Class vs Interface: When to Choose Which

This comes up constantly, and the answer depends on what you need at runtime.

Use an interface when:

  • You're describing the shape of data, with no behavior needed
  • You want the type to disappear at runtime (interfaces compile away to nothing)
  • You need a type that multiple classes can implement
  • You're typing function parameters or API responses

Use a class when:

  • You need to create instances with new
  • You have shared methods, not just a data shape
  • You need a constructor to enforce initialization
  • You want instanceof checks to work at runtime
// Interface: just a shape, no runtime footprint
interface UserData {
id: string;
email: string;
createdAt: Date;
}

// Class: has behavior and can be instantiated
class UserService {
private users: UserData[] = [];

addUser(user: UserData) {
this.users.push(user);
}

findById(id: string): UserData | undefined {
return this.users.find(u => u.id === id);
}
}

A common pattern is to use both together: define the shape with an interface, then implement behavior in a class. This gives you type safety without tying the shape to a specific implementation.

Using Decorators

Decorators add behavior to a class, method, or property without modifying its original code. They're functions that wrap the original and inject extra logic around it.

Since TypeScript 5.0, decorators are a standard feature and no longer experimental. If you're on an older version, you'll need "experimentalDecorators": true in your tsconfig.json.

// Decorator factory — returns a decorator function
function LogMethod(prefix: string) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;

descriptor.value = function(...args: any[]) {
console.log(`${prefix} Calling ${propertyKey} with:`, args);
const result = originalMethod.apply(this, args);
console.log(`${prefix} Result:`, result);
return result;
};

return descriptor;
};
}

// Property decorator that enforces a required value
function Required(target: any, propertyKey: string) {
let value: any;

Object.defineProperty(target, propertyKey, {
get: () => value,
set: (newVal: any) => {
if (newVal === undefined || newVal === null) {
throw new Error(`${propertyKey} is required`);
}
value = newVal;
},
enumerable: true,
configurable: true
});
}

class Calculator {
@Required
name: string;

constructor(name: string) {
this.name = name;
}

@LogMethod("CALC")
add(a: number, b: number) {
return a + b;
}

@LogMethod("CALC")
multiply(a: number, b: number) {
return a * b;
}
}

const calc = new Calculator("MyCalculator");
calc.add(5, 3); // Logs the call and result
calc.multiply(2, 4); // Logs the call and result

Decorators are a clean way to handle cross-cutting concerns like logging and validation without cluttering your business logic. They're similar to custom functions in Convex, which let you wrap behavior in a reusable way.

Where Developers Get Stuck

Property Initialization

TypeScript's strict mode flags class properties that aren't definitely assigned in the constructor. You have a few options:

class UserRecord {
// Option 1: Initialize inline
role: string = "user";

// Option 2: Initialize in the constructor
name: string;

// Option 3: Non-null assertion — use sparingly
id!: string; // The ! tells TypeScript "trust me, this will be set"

constructor(name: string) {
this.name = name;
// id might be set later, e.g., after an async operation
}
}

Reach for ! only when you're confident the property will be set before it's read — for example, when a dependency injection framework populates it after construction.

Constructor Parameter Shorthand

You don't need to declare properties and then assign them in the constructor separately. TypeScript's parameter property shorthand handles both at once:

// Verbose approach
class Person {
name: string;
age: number;

constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
}

// Concise with parameter properties — same result
class Person {
constructor(
public name: string,
public age: number,
private ssn: string
) {
// No need to assign this.name = name, etc.
}
}

This is idiomatic TypeScript. Use it when a class mostly holds data without complex constructor logic.

Method Overriding Issues

When extending classes, the overriding method's parameter types must match the base class:

class Base {
calculate(value: number): number {
return value * 2;
}
}

class Derived extends Base {
// Error: parameter type doesn't match base class
// calculate(value: string): number { ... }

// Correct: same parameter types as the base method
calculate(value: number): number {
const baseResult = super.calculate(value);
return baseResult + 10;
}
}

These patterns align with the TypeScript best practices for production TypeScript development.

TypeScript Classes: Key Takeaways

TypeScript classes give you a structured way to organize code using object-oriented principles, with static typing to catch mistakes before they reach production.

A few rules of thumb:

  • Default to private for properties and only expose what consumers actually need
  • Use constructor parameter shorthand to cut down on boilerplate
  • Reach for an interface when you only need a shape; use a class when you need behavior and instances
  • Use abstract classes when you have shared implementation but want subclasses to fill in specific parts
  • Getters and setters let you add validation logic without changing how callers interact with properties

Whether you're managing user data, building service layers, or working with a backend platform like Convex, TypeScript classes help you write safer, more reusable code.