Classes in TypeScript
Use access modifiers, abstract classes, parameter properties, and interface implementation to write well-structured object-oriented TypeScript.
Class Basics
TypeScript classes build on ES2015 classes and add type annotations for properties and methods. The key benefit over plain JavaScript classes is that TypeScript verifies all property accesses and method calls — mistyped property names, wrong argument types, and missing fields are all caught before runtime.
class Animal {
name: string; // property declaration with type
constructor(name: string) {
this.name = name;
}
speak(): string {
return `${this.name} makes a sound.`;
}
}
const dog = new Animal("Rex");
dog.speak(); // "Rex makes a sound."
dog.color; // Error: Property 'color' does not exist on type 'Animal'
Access Modifiers
Access modifiers control where class members can be read and written. They enforce encapsulation at the type level — hiding implementation details and making the public API of a class explicit. TypeScript checks these at compile time.
public (the default) — accessible from anywhere:
class User {
public name: string; // same as just: name: string
}
private — accessible only within the class body. External code cannot read or write the field, protecting internal state:
class BankAccount {
private balance: number; // hidden from outside — only accessible via methods
constructor(initialBalance: number) {
this.balance = initialBalance;
}
deposit(amount: number): void {
this.balance += amount;
}
getBalance(): number {
return this.balance; // controlled read-only access
}
}
const account = new BankAccount(100);
account.deposit(50);
account.balance; // Error: Property 'balance' is private
protected — accessible within the class and any subclass, but not from outside the hierarchy. Useful for sharing implementation details with child classes without exposing them publicly:
class Vehicle {
protected speed: number = 0; // subclasses can read and write this
accelerate(amount: number): void {
this.speed += amount;
}
}
class Car extends Vehicle {
describe(): string {
return `Speed: ${this.speed}`; // fine — Car is a subclass of Vehicle
}
}
const car = new Car();
car.speed; // Error — protected, not accessible from outside the class hierarchy
ECMAScript Private Fields
TypeScript’s private modifier is erased at compile time — the property is still accessible in the JavaScript output. For truly runtime-enforced privacy, use the ECMAScript # syntax. This matters when you’re writing libraries or dealing with code that might bypass TypeScript’s checks.
class Counter {
#count = 0; // true runtime privacy — enforced by the JS engine, not just TypeScript
increment(): void {
this.#count++;
}
get value(): number {
return this.#count;
}
}
const c = new Counter();
c.increment();
c.value; // 1
c.#count; // SyntaxError at runtime — not just a TypeScript error
Prefer # for sensitive data where runtime guarantees matter. Use TypeScript’s private for internal organization where compile-time checking is sufficient.
Parameter Properties
Declaring a class property, assigning it in the constructor body, and typing it all require repetitive boilerplate. Parameter properties eliminate this by letting you declare and initialize fields directly in the constructor signature, making simple data classes much more concise.
// Without parameter properties — lots of repetition
class Point {
x: number;
y: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
}
// With parameter properties — same result, much less code
class Point {
constructor(
public x: number,
public y: number
) {}
}
Works with any access modifier or readonly:
class User {
constructor(
public readonly id: number, // public and immutable after construction
public name: string, // public and mutable
private email: string // private, hidden from outside
) {}
}
const user = new User(1, "Alice", "alice@example.com");
user.id = 99; // Error: Cannot assign to 'id' because it is a read-only property
Readonly Properties
Readonly properties can only be set during construction. They communicate that a value is fixed for the lifetime of the object — useful for configuration objects, value types, and any class where immutability is important.
class Config {
readonly maxRetries: number;
readonly baseUrl: string;
constructor(maxRetries: number, baseUrl: string) {
// Can be set in the constructor
this.maxRetries = maxRetries;
this.baseUrl = baseUrl;
}
// After construction, these fields can never change
}
Getters and Setters
Getters and setters let you expose a property-like API while running validation or transformation logic behind the scenes. They’re the right choice when you need to compute a derived value or enforce constraints on assignment.
class Temperature {
private _celsius: number;
constructor(celsius: number) {
this._celsius = celsius;
}
get celsius(): number {
return this._celsius;
}
set celsius(value: number) {
// Validate before accepting the new value
if (value < -273.15) throw new RangeError("Below absolute zero");
this._celsius = value;
}
// Derived value — computed from the stored celsius, no separate field needed
get fahrenheit(): number {
return this._celsius * 9/5 + 32;
}
}
const t = new Temperature(100);
t.fahrenheit; // 212
t.celsius = -300; // RangeError — the setter enforces the constraint
Implementing Interfaces
A class can declare that it implements one or more interfaces with the implements keyword. TypeScript then verifies that every method and property in the interface is present and correctly typed. This is a powerful way to enforce contracts and make classes interchangeable with each other.
interface Serializable {
serialize(): string;
deserialize(data: string): void;
}
interface Loggable {
log(): void;
}
// TypeScript errors immediately if any required method is missing or wrongly typed
class UserRecord implements Serializable, Loggable {
constructor(private id: number, private name: string) {}
serialize(): string {
return JSON.stringify({ id: this.id, name: this.name });
}
deserialize(data: string): void {
const parsed = JSON.parse(data);
this.name = parsed.name;
}
log(): void {
console.log(`User ${this.id}: ${this.name}`);
}
}
Abstract Classes
Abstract classes define a blueprint that concrete subclasses must complete. Unlike interfaces, they can contain real method implementations that subclasses inherit. You can’t instantiate an abstract class directly — it’s a guarantee that the class is only ever used as a base.
This is the right tool when you have shared behavior to provide but also have operations that must be customized per subclass.
abstract class Shape {
// Subclasses must implement these — no default implementation possible
abstract area(): number;
abstract perimeter(): number;
// Shared behavior all shapes get for free
describe(): string {
return `Area: ${this.area().toFixed(2)}, Perimeter: ${this.perimeter().toFixed(2)}`;
}
}
class Circle extends Shape {
constructor(private radius: number) {
super();
}
area(): number {
return Math.PI * this.radius ** 2;
}
perimeter(): number {
return 2 * Math.PI * this.radius;
}
}
class Rectangle extends Shape {
constructor(private width: number, private height: number) {
super();
}
area(): number {
return this.width * this.height;
}
perimeter(): number {
return 2 * (this.width + this.height);
}
}
// Both share the describe() method from Shape
const shapes: Shape[] = [new Circle(5), new Rectangle(4, 6)];
shapes.forEach((s) => console.log(s.describe()));
new Shape(); // Error: Cannot create an instance of an abstract class
Static Members
Static properties and methods belong to the class constructor itself, not to instances. They’re useful for utility functions, constants, and factory methods that don’t need access to instance data.
class MathUtils {
static readonly PI = 3.14159265358979;
static circleArea(radius: number): number {
return MathUtils.PI * radius ** 2;
}
static clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
}
// Called on the class, not on an instance
MathUtils.circleArea(5); // 78.539...
MathUtils.clamp(150, 0, 100); // 100
Class as Type
In TypeScript, a class declaration creates two things simultaneously: a value (the constructor function) and a type (the shape of instances). Understanding the difference lets you type both instances and constructors precisely.
class Dog {
constructor(public name: string) {}
bark() { return "Woof!"; }
}
// Dog as a type — describes the shape of an instance
function greet(dog: Dog): string {
return `Hello, ${dog.name}!`;
}
// typeof Dog — describes the constructor itself, for factory patterns
function createDog(Ctor: typeof Dog, name: string): Dog {
return new Ctor(name);
}
Practical Example: A Service Base Class
This example combines abstract classes, generics, and access modifiers to build a reusable base service. Concrete services inherit the CRUD operations and only need to implement the domain-specific logic.
abstract class BaseService<T extends { id: number }> {
// Protected so subclasses can access the store for custom queries
protected items: Map<number, T> = new Map();
findById(id: number): T | undefined {
return this.items.get(id);
}
findAll(): T[] {
return Array.from(this.items.values());
}
save(item: T): T {
this.items.set(item.id, item);
return item;
}
delete(id: number): boolean {
return this.items.delete(id);
}
// Each subclass defines its own validation rules
abstract validate(item: T): boolean;
}
interface User {
id: number;
name: string;
email: string;
}
class UserService extends BaseService<User> {
// Domain-specific validation
validate(user: User): boolean {
return user.name.length > 0 && user.email.includes("@");
}
// Domain-specific query not in the base class
findByEmail(email: string): User | undefined {
return this.findAll().find((u) => u.email === email);
}
}
const svc = new UserService();
svc.save({ id: 1, name: "Alice", email: "alice@example.com" });
svc.findByEmail("alice@example.com"); // User