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.53981633974483Notice 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
_firstName + ' ' + _lastNameget fullName()Read AccessConcatenate first and last name
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 zeroThe 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 100Type 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 stringConsistency 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 dateThe 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); // 0Pattern 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
superkeyword - 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.