Logo

Home

About

Blog

Contact

Buy Merch

Portfolio

Privacy

TOS

Click to navigate

  1. Home
  2. Joshua R. Lehman's Blog
  3. Getters, Setters, and Computed Properties

Table of contents

  • Share on X
  • Discuss on X

Related Articles

Access Modifiers: public, private, protected
TypeScript
8m
Feb 15, 2026

Access Modifiers: public, private, protected

A comprehensive guide to TypeScript access modifiers covering public, private, and protected keywords with practical examples of encapsulation and data protection.

#Access Modifiers#Encapsulation+5
Classes in TypeScript: The Basics
TypeScript
7m
Feb 8, 2026

Classes in TypeScript: The Basics

A comprehensive introduction to TypeScript classes covering properties, methods, constructors, and object instantiation with type safety.

#TypeScript Classes#OOP+5
Mastering TypeScript Utility Types
TypeScript
9m
Feb 1, 2026

Mastering TypeScript Utility Types

TypeScript provides powerful built-in utility types that help you transform existing types without rewriting them. This guide covers the most essential utility types and how to use them effectively in your projects.

#TypeScript#Utility Types+4
Ask me anything! 💬

© Joshua R. Lehman

Full Stack Developer

Crafted with passion • Built with modern web technologies

2026 • All rights reserved

Contents

  • Understanding Accessors
  • Getters: Reading with Logic
  • Setters: Writing with Validation
  • Computed Properties
  • Read-Only Properties
  • Validation Patterns
  • Common Patterns and Best Practices
  • When Not to Use Accessors
TypeScript

Getters, Setters, and Computed Properties

February 22, 2026•12 min read
Joshua R. Lehman
Joshua R. Lehman
Author
TypeScript getters and setters for property access control
Getters, Setters, and Computed Properties

Understanding Accessors

Getters and setters are special methods that let you define custom behavior when reading or writing a property. They look like regular properties from the outside, but run code internally. This gives you fine-grained control over how data is accessed and modified.

Think of accessors as gatekeepers. A getter is the guard who checks your ID when you want to read a value. A setter is the guard who validates what you're trying to write. Both ensure the rules are followed.

In TypeScript, you define accessors with the get and set keywords. From the caller's perspective, they look like normal property access—no parentheses needed. But behind the scenes, your custom logic runs every time.

Why Use Accessors?

Accessors let you add validation, computed values, logging, and side effects to property access without changing how external code uses your class. You can start with direct properties and add accessors later without breaking existing code.

Getters: Reading with Logic

A getter is a method that runs when you read a property. It's defined with the get keyword and must return a value.

Basic Getter

class Circle {
  private _radius: number;
 
  constructor(radius: number) {
    this._radius = radius;
  }
 
  get radius(): number {
    return this._radius;
  }
 
  get diameter(): number {
    return this._radius * 2;
  }
 
  get circumference(): number {
    return 2 * Math.PI * this._radius;
  }
 
  get area(): number {
    return Math.PI * this._radius * this._radius;
  }
}
 
const circle = new Circle(5);
 
// Access like properties (no parentheses!)
console.log(circle.radius); // 5
console.log(circle.diameter); // 10
console.log(circle.circumference); // 31.41592653589793
console.log(circle.area); // 78.53981633974483

Notice how diameter, circumference, and area are computed from _radius. They're calculated on-demand when accessed, not stored separately.

Getter with Formatting

Getters are perfect for formatting data on read:

class User {
  private _firstName: string;
  private _lastName: string;
  private _email: string;
 
  constructor(firstName: string, lastName: string, email: string) {
    this._firstName = firstName;
    this._lastName = lastName;
    this._email = email;
  }
 
  get fullName(): string {
    return `${this._firstName} ${this._lastName}`;
  }
 
  get displayEmail(): string {
    const [username, domain] = this._email.split("@");
    return `${username}@${domain.toUpperCase()}`;
  }
 
  get initials(): string {
    return `${this._firstName[0]}${this._lastName[0]}`.toUpperCase();
  }
}
 
const user = new User("Alice", "Johnson", "[email protected]");
 
console.log(user.fullName); // "Alice Johnson"
console.log(user.displayEmail); // "[email protected]"
console.log(user.initials); // "AJ"

Each getter computes its value from the private properties. The data is stored once, but presented in multiple formats.

fullName

Property Accessor Pattern

🗃️
Internal Storage
_firstName + ' ' + _lastName
get fullName()Read Access

Concatenate first and last name

Usage:
const value = obj.fullName;

Read-Only Property: Uses get accessor only

Setters: Writing with Validation

A setter is a method that runs when you write to a property. It's defined with the set keyword and receives the new value as a parameter.

Basic Setter

class Temperature {
  private _celsius: number = 0;
 
  get celsius(): number {
    return this._celsius;
  }
 
  set celsius(value: number) {
    if (value < -273.15) {
      throw new Error("Temperature cannot be below absolute zero");
    }
    this._celsius = value;
  }
 
  get fahrenheit(): number {
    return (this._celsius * 9) / 5 + 32;
  }
 
  set fahrenheit(value: number) {
    this.celsius = ((value - 32) * 5) / 9; // Reuse celsius setter
  }
}
 
const temp = new Temperature();
 
temp.celsius = 25;
console.log(temp.celsius); // 25
console.log(temp.fahrenheit); // 77
 
temp.fahrenheit = 32;
console.log(temp.celsius); // 0
console.log(temp.fahrenheit); // 32
 
temp.celsius = -300; // Error: Temperature cannot be below absolute zero

The setter validates that temperature stays above absolute zero. Invalid values are rejected before they can corrupt the object's state.

Validation in Setters

Always validate inputs in setters. Once a property has an invalid value, debugging becomes much harder. Fail fast at the point of assignment with clear error messages.

Setter with Sanitization

Setters can clean or normalize data before storing it:

class Contact {
  private _phone: string = "";
  private _email: string = "";
 
  get phone(): string {
    return this._phone;
  }
 
  set phone(value: string) {
    // Remove all non-numeric characters
    const cleaned = value.replace(/\D/g, "");
 
    if (cleaned.length !== 10) {
      throw new Error("Phone number must be 10 digits");
    }
 
    // Store in consistent format
    this._phone = cleaned;
  }
 
  get phoneFormatted(): string {
    // Format as (XXX) XXX-XXXX
    return `(${this._phone.slice(0, 3)}) ${this._phone.slice(3, 6)}-${this._phone.slice(6)}`;
  }
 
  get email(): string {
    return this._email;
  }
 
  set email(value: string) {
    // Normalize to lowercase
    const normalized = value.trim().toLowerCase();
 
    if (!normalized.includes("@")) {
      throw new Error("Invalid email address");
    }
 
    this._email = normalized;
  }
}
 
const contact = new Contact();
 
contact.phone = "(555) 123-4567";
console.log(contact.phone); // "5551234567"
console.log(contact.phoneFormatted); // "(555) 123-4567"
 
contact.phone = "555.123.4567";
console.log(contact.phone); // "5551234567" (same normalized format)
 
contact.email = "  [email protected]  ";
console.log(contact.email); // "[email protected]"

The setters ensure phone numbers and emails are stored in a consistent format, regardless of how users input them.

Computed Properties

Computed properties are getters that derive their value from other properties. They're not stored—they're calculated on demand.

Why Compute vs Store?

Computed (getter):

class Rectangle {
  width: number;
  height: number;
 
  constructor(width: number, height: number) {
    this.width = width;
    this.height = height;
  }
 
  // Computed - always current
  get area(): number {
    return this.width * this.height;
  }
 
  get perimeter(): number {
    return 2 * (this.width + this.height);
  }
}
 
const rect = new Rectangle(10, 5);
console.log(rect.area); // 50
 
rect.width = 20;
console.log(rect.area); // 100 (automatically updated!)

Stored (property):

class RectangleBad {
  width: number;
  height: number;
  area: number; // Stored - can get out of sync!
 
  constructor(width: number, height: number) {
    this.width = width;
    this.height = height;
    this.area = width * height;
  }
}
 
const rectBad = new RectangleBad(10, 5);
console.log(rectBad.area); // 50
 
rectBad.width = 20;
console.log(rectBad.area); // Still 50! (stale data)

Computed properties stay synchronized automatically. Stored properties can drift out of sync.

Performance Consideration

Computed properties recalculate on every access. For expensive computations accessed frequently, consider caching the result and invalidating when dependencies change. For simple calculations (like area), the overhead is negligible.

Complex Computed Properties

class ShoppingCart {
  private items: Array<{ name: string; price: number; quantity: number }> = [];
 
  addItem(name: string, price: number, quantity: number): void {
    this.items.push({ name, price, quantity });
  }
 
  get itemCount(): number {
    return this.items.reduce((sum, item) => sum + item.quantity, 0);
  }
 
  get subtotal(): number {
    return this.items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    );
  }
 
  get tax(): number {
    return this.subtotal * 0.08; // 8% tax
  }
 
  get total(): number {
    return this.subtotal + this.tax;
  }
 
  get summary(): string {
    return `${this.itemCount} items: \$${this.total.toFixed(2)} (tax: \$${this.tax.toFixed(2)})`;
  }
}
 
const cart = new ShoppingCart();
cart.addItem("Laptop", 1200, 1);
cart.addItem("Mouse", 25, 2);
 
console.log(cart.itemCount); // 3
console.log(cart.subtotal); // 1250
console.log(cart.tax); // 100
console.log(cart.total); // 1350
console.log(cart.summary); // "3 items: \$1350.00 (tax: \$100.00)"

Each computed property builds on others. They all update automatically when items change.

Read-Only Properties

A getter without a setter creates a read-only property:

class BankAccount {
  private _balance: number;
  private readonly _accountNumber: string;
  private _transactionCount: number = 0;
 
  constructor(accountNumber: string, initialBalance: number) {
    this._accountNumber = accountNumber;
    this._balance = initialBalance;
  }
 
  // Read-only - no setter
  get accountNumber(): string {
    return this._accountNumber;
  }
 
  // Read-only - no setter
  get balance(): number {
    return this._balance;
  }
 
  // Read-only - no setter
  get transactionCount(): number {
    return this._transactionCount;
  }
 
  deposit(amount: number): void {
    if (amount <= 0) {
      throw new Error("Deposit amount must be positive");
    }
    this._balance += amount;
    this._transactionCount++;
  }
 
  withdraw(amount: number): boolean {
    if (amount <= 0) {
      throw new Error("Withdrawal amount must be positive");
    }
 
    if (amount > this._balance) {
      return false; // Insufficient funds
    }
 
    this._balance -= amount;
    this._transactionCount++;
    return true;
  }
}
 
const account = new BankAccount("ACC001", 1000);
 
console.log(account.balance); // 1000
console.log(account.accountNumber); // "ACC001"
 
account.deposit(500);
console.log(account.balance); // 1500
 
// account.balance = 5000; // Error: Cannot assign to 'balance' because it is a read-only property
// account.accountNumber = "HACKED"; // Error: Cannot assign to 'accountNumber'

External code can read these properties but cannot write to them. All modifications go through controlled methods (deposit, withdraw).

Validation Patterns

Range Validation

class Percentage {
  private _value: number = 0;
 
  get value(): number {
    return this._value;
  }
 
  set value(val: number) {
    if (val < 0 || val > 100) {
      throw new Error("Percentage must be between 0 and 100");
    }
    this._value = val;
  }
 
  get decimal(): number {
    return this._value / 100;
  }
 
  get formatted(): string {
    return `${this._value}%`;
  }
}
 
const discount = new Percentage();
discount.value = 25;
console.log(discount.formatted); // "25%"
console.log(discount.decimal); // 0.25
 
discount.value = 150; // Error: Percentage must be between 0 and 100

Type Validation

class Config {
  private _port: number = 3000;
  private _host: string = "localhost";
 
  get port(): number {
    return this._port;
  }
 
  set port(value: number) {
    if (!Number.isInteger(value)) {
      throw new Error("Port must be an integer");
    }
 
    if (value < 1 || value > 65535) {
      throw new Error("Port must be between 1 and 65535");
    }
 
    this._port = value;
  }
 
  get host(): string {
    return this._host;
  }
 
  set host(value: string) {
    if (typeof value !== "string" || value.trim().length === 0) {
      throw new Error("Host must be a non-empty string");
    }
 
    this._host = value.trim();
  }
 
  get url(): string {
    return `http://${this._host}:${this._port}`;
  }
}
 
const config = new Config();
config.port = 8080;
config.host = "example.com";
 
console.log(config.url); // "http://example.com:8080"
 
config.port = 99999; // Error: Port must be between 1 and 65535
config.host = "   "; // Error: Host must be a non-empty string

Consistency Validation

class DateRange {
  private _start: Date;
  private _end: Date;
 
  constructor(start: Date, end: Date) {
    this._start = start;
    this._end = end;
    this.validate();
  }
 
  get start(): Date {
    return this._start;
  }
 
  set start(value: Date) {
    this._start = value;
    this.validate();
  }
 
  get end(): Date {
    return this._end;
  }
 
  set end(value: Date) {
    this._end = value;
    this.validate();
  }
 
  private validate(): void {
    if (this._start > this._end) {
      throw new Error("Start date must be before end date");
    }
  }
 
  get duration(): number {
    return this._end.getTime() - this._start.getTime();
  }
 
  get durationDays(): number {
    return this.duration / (1000 * 60 * 60 * 24);
  }
}
 
const range = new DateRange(new Date("2024-01-01"), new Date("2024-12-31"));
console.log(range.durationDays); // 365
 
range.start = new Date("2024-06-01");
console.log(range.durationDays); // 214
 
range.end = new Date("2024-01-01"); // Error: Start date must be before end date

The validate method ensures start is always before end, regardless of which property changed.

Cross-Property Validation

When properties depend on each other (like start and end dates), validate in both setters or use a shared validation method. This prevents one property from being set to a value that's invalid relative to the other.

Common Patterns and Best Practices

Pattern 1: Lazy Initialization

Compute expensive values only when first accessed:

class Report {
  private _data: number[];
  private _statistics?: {
    mean: number;
    median: number;
    stdDev: number;
  };
 
  constructor(data: number[]) {
    this._data = data;
  }
 
  get statistics() {
    if (!this._statistics) {
      // Expensive computation - only once
      this._statistics = this.calculateStatistics();
    }
    return this._statistics;
  }
 
  private calculateStatistics() {
    const sorted = [...this._data].sort((a, b) => a - b);
    const mean = this._data.reduce((a, b) => a + b, 0) / this._data.length;
    const median = sorted[Math.floor(sorted.length / 2)];
 
    const variance =
      this._data.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) /
      this._data.length;
    const stdDev = Math.sqrt(variance);
 
    return { mean, median, stdDev };
  }
}
 
const report = new Report([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
 
// First access: calculates
console.log(report.statistics.mean); // 5.5
 
// Subsequent accesses: uses cached value
console.log(report.statistics.median); // 5
console.log(report.statistics.stdDev); // 2.87...

Pattern 2: Property Transformation

Convert between different representations:

class Color {
  private _r: number;
  private _g: number;
  private _b: number;
 
  constructor(r: number, g: number, b: number) {
    this._r = this.clamp(r);
    this._g = this.clamp(g);
    this._b = this.clamp(b);
  }
 
  private clamp(value: number): number {
    return Math.max(0, Math.min(255, Math.round(value)));
  }
 
  get r(): number {
    return this._r;
  }
  set r(value: number) {
    this._r = this.clamp(value);
  }
 
  get g(): number {
    return this._g;
  }
  set g(value: number) {
    this._g = this.clamp(value);
  }
 
  get b(): number {
    return this._b;
  }
  set b(value: number) {
    this._b = this.clamp(value);
  }
 
  get hex(): string {
    const toHex = (n: number) => n.toString(16).padStart(2, "0");
    return `#${toHex(this._r)}${toHex(this._g)}${toHex(this._b)}`;
  }
 
  set hex(value: string) {
    const hex = value.replace("#", "");
    this._r = parseInt(hex.substr(0, 2), 16);
    this._g = parseInt(hex.substr(2, 2), 16);
    this._b = parseInt(hex.substr(4, 2), 16);
  }
 
  get rgb(): string {
    return `rgb(${this._r}, ${this._g}, ${this._b})`;
  }
}
 
const color = new Color(255, 100, 50);
console.log(color.hex); // "#ff6432"
console.log(color.rgb); // "rgb(255, 100, 50)"
 
color.hex = "#00ff00";
console.log(color.r); // 0
console.log(color.g); // 255
console.log(color.b); // 0

Pattern 3: Change Notification

Trigger side effects when properties change:

class ObservableValue {
  private _value: number = 0;
  private listeners: Array<(oldValue: number, newValue: number) => void> = [];
 
  get value(): number {
    return this._value;
  }
 
  set value(newValue: number) {
    const oldValue = this._value;
 
    if (oldValue !== newValue) {
      this._value = newValue;
      this.notifyListeners(oldValue, newValue);
    }
  }
 
  subscribe(listener: (oldValue: number, newValue: number) => void): void {
    this.listeners.push(listener);
  }
 
  private notifyListeners(oldValue: number, newValue: number): void {
    this.listeners.forEach((listener) => listener(oldValue, newValue));
  }
}
 
const observable = new ObservableValue();
 
observable.subscribe((old, newVal) => {
  console.log(`Value changed from ${old} to ${newVal}`);
});
 
observable.value = 10; // "Value changed from 0 to 10"
observable.value = 20; // "Value changed from 10 to 20"
observable.value = 20; // (no output - same value)

When Not to Use Accessors

Don't Use for Simple Pass-Through

// ❌ Bad: Unnecessary boilerplate
class User {
  private _name: string;
 
  get name(): string {
    return this._name;
  }
 
  set name(value: string) {
    this._name = value;
  }
}
 
// ✅ Better: Just use public property
class UserGood {
  public name: string;
 
  constructor(name: string) {
    this.name = name;
  }
}

If you're not adding validation, computation, or side effects, skip the accessors.

Don't Use for Expensive Computations Without Caching

// ❌ Bad: Recalculates on every access
class DataSet {
  data: number[];
 
  get average(): number {
    // Expensive! Runs every time you access it
    return this.data.reduce((a, b) => a + b, 0) / this.data.length;
  }
}
 
// ✅ Better: Cache the result
class DataSetGood {
  private _data: number[];
  private _average?: number;
 
  set data(value: number[]) {
    this._data = value;
    this._average = undefined; // Invalidate cache
  }
 
  get data(): number[] {
    return this._data;
  }
 
  get average(): number {
    if (this._average === undefined) {
      this._average = this._data.reduce((a, b) => a + b, 0) / this._data.length;
    }
    return this._average;
  }
}

Don't Use for Actions

// ❌ Bad: Setter has side effects beyond setting a value
class Logger {
  set message(value: string) {
    console.log(value); // Side effect!
  }
}
 
// ✅ Better: Use a method
class LoggerGood {
  log(message: string): void {
    console.log(message);
  }
}

Setters should set values, not perform complex actions. Use methods for actions.

What's Next

You now understand getters, setters, and computed properties. In the next posts, you'll learn:

  • Static members for class-level properties and methods
  • Abstract classes for creating blueprints
  • Class inheritance and the super keyword
  • Implementing interfaces in classes

Key Takeaways

Getters and setters give you:

✅ Controlled access to properties with validation and formatting ✅ Computed properties that stay synchronized automatically ✅ Read-only properties that protect internal state ✅ Encapsulation that hides implementation details ✅ Backward compatibility - add logic without changing external APIs ✅ Clean syntax - look like properties, act like methods

Use getters for derived values and formatting. Use setters for validation and normalization. Keep them simple and avoid side effects. When you just need to store a value with no logic, use a regular property instead.