TypeScriptGetters & Setters

Getters & Setters

Getters and setters let you intercept property reads and writes on a class. Instead of exposing a raw field you expose a computed property backed by custom logic — validation, lazy evaluation, change tracking, and more.

TypeScript inherits the get / set accessor syntax from JavaScript but adds static type checking so the types of the getter return value and the setter parameter are verified at compile time.

Basic syntax

Prefix a method with get to create a getter, and set to create a setter. From the outside they look exactly like a plain property — no parentheses, no special call syntax.

TS
class Temperature {
  private _celsius: number = 0;

  get fahrenheit(): number {
    return this._celsius * 9 / 5 + 32;
  }

  set fahrenheit(value: number) {
    this._celsius = (value - 32) * 5 / 9;
  }

  get celsius(): number {
    return this._celsius;
  }

  set celsius(value: number) {
    if (value < -273.15) {
      throw new RangeError('Temperature below absolute zero');
    }
    this._celsius = value;
  }
}

const t = new Temperature();
t.celsius = 100;
console.log(t.fahrenheit); // 212
t.fahrenheit = 32;
console.log(t.celsius);    // 0
Note
Callers use t.celsius and t.fahrenheit as plain properties. The accessor syntax is completely transparent to the outside world.
Read-only getters (no setter)

A getter without a corresponding setter produces a read-only virtual property. TypeScript raises a compile-time error if you attempt to assign to it.

TS
class Circle {
  constructor(private _radius: number) {}

  get radius(): number { return this._radius; }

  get area(): number {
    return Math.PI * this._radius ** 2;
  }

  get circumference(): number {
    return 2 * Math.PI * this._radius;
  }
}

const c = new Circle(5);
console.log(c.area.toFixed(2));          // 78.54
console.log(c.circumference.toFixed(2)); // 31.42

// c.area = 100;
// Error: Cannot assign to 'area' because it is a read-only property.
Validation in setters

Setters are the natural home for validation logic. They act as a firewall between external code and internal state, ensuring the object is always in a valid, consistent state.

TS
class User {
  private _email = '';
  private _age   = 0;

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

  set email(value: string) {
    const trimmed = value.trim();
    if (!trimmed.includes('@') || !trimmed.includes('.')) {
      throw new TypeError(`Invalid email: "${value}"`);
    }
    this._email = trimmed.toLowerCase();
  }

  get age(): number { return this._age; }

  set age(value: number) {
    if (!Number.isInteger(value) || value < 0 || value > 150) {
      throw new RangeError(`Age must be 0–150, got ${value}`);
    }
    this._age = value;
  }
}

const user = new User();
user.email = '  Hello@Example.COM  ';
console.log(user.email); // 'hello@example.com'

user.age = 25;
// user.age = -1;  // RangeError: Age must be 0–150, got -1
Lazy / cached computation

Getters are recomputed on every access. For expensive calculations you can memoize — compute once, cache the result, invalidate the cache when the underlying data changes.

TS
class Document {
  private _content: string;
  private _wordCount: number | null = null;

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

  get wordCount(): number {
    if (this._wordCount === null) {
      console.log('(computing word count…)');
      this._wordCount = this._content
        .trim()
        .split(/\s+/)
        .filter(Boolean).length;
    }
    return this._wordCount;
  }

  set content(value: string) {
    this._content = value;
    this._wordCount = null; // invalidate cache
  }
}

const doc = new Document('Hello world foo bar');
console.log(doc.wordCount); // (computing…) → 4
console.log(doc.wordCount); // → 4  (no recomputation)
doc.content = 'New content here';
console.log(doc.wordCount); // (computing…) → 3
Tip
Lazy getters trade slightly higher first-access cost for lower startup cost. Use them for expensive operations that may not be needed on every code path.
Getters in interfaces

Interfaces describe getters as readonly properties. Any class that exposes the property — whether via a getter or a plain readonly field — satisfies the contract.

TS
interface Shape {
  readonly area: number;
  readonly perimeter: number;
  describe(): string;
}

class Rectangle implements Shape {
  constructor(private w: number, private h: number) {}

  get area(): number      { return this.w * this.h; }
  get perimeter(): number { return 2 * (this.w + this.h); }

  describe(): string {
    return `Rectangle ${this.w}x${this.h}: area=${this.area}, perimeter=${this.perimeter}`;
  }
}

class Square implements Shape {
  constructor(private s: number) {}

  get area(): number      { return this.s ** 2; }
  get perimeter(): number { return 4 * this.s; }

  describe(): string {
    return `Square ${this.s}x${this.s}: area=${this.area}, perimeter=${this.perimeter}`;
  }
}

const shapes: Shape[] = [new Rectangle(4, 6), new Square(5)];
shapes.forEach(s => console.log(s.describe()));
Overriding accessors in subclasses

TS
class Animal {
  private _name: string;

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

  get name(): string            { return this._name; }
  set name(v: string)           { this._name = v.trim(); }
}

class Dog extends Animal {
  override get name(): string   { return `Dog: ${super.name}`; }
  // Must also keep the setter when overriding the getter
  override set name(v: string)  { super.name = v; }
}

const d = new Dog('Rex');
console.log(d.name); // Dog: Rex
d.name = 'Buddy';
console.log(d.name); // Dog: Buddy
Warning
When you override a getter in a subclass you must also override its setter (if the base had one). Omitting the setter silently makes the property read-only in the subclass — almost always a bug.
The auto-accessor shorthand (TypeScript 4.9+)

TypeScript 4.9 introduced the accessor keyword, which auto-generates a private backing field plus a getter/setter pair. It produces identical runtime behaviour to the manual version but with less boilerplate — and is especially useful when combined with decorators.

TS
// Manual (pre-4.9 style)
class OldStyle {
  private _x = 0;
  get x() { return this._x; }
  set x(v: number) { this._x = v; }
}

// auto-accessor shorthand (4.9+)
class NewStyle {
  accessor x: number = 0;
}

// Both compile to equivalent JavaScript.
// 'accessor' is particularly useful with decorators:
function logged(target: ClassAccessorDecoratorTarget<unknown, number>,
                ctx: ClassAccessorDecoratorContext) {
  return {
    get(this: unknown) { const v = target.get.call(this); console.log('get', v); return v; },
    set(this: unknown, v: number) { console.log('set', v); target.set.call(this, v); },
  };
}

class Counter {
  @logged accessor count = 0;
}

const counter = new Counter();
counter.count = 5;  // logs: set 5
console.log(counter.count); // logs: get 5 → 5
Getters vs methods — choosing the right tool

Scenario

Getter

Method

Derived value, no parameters

✅ preferred

Expensive computation (cacheable)

✅ + memoize

Operation needs arguments

✅ required

Side effects (API call, I/O)

✅ clearer intent

Validation on write

✅ setter

Change tracking / reactivity

✅ setter

Common pitfalls
  1. Infinite recursion: writing this.name = value inside the setter for name calls the setter again. Always write to the backing field: this._name = value.

  2. Forgetting to declare the private backing field — the getter/setter has nowhere to store the value.

  3. Mismatched getter/setter types. TypeScript allows them to differ but it is almost always a mistake; keep them consistent.

  4. Expensive logic in getters called in tight loops — profile before deciding to memoize.

Success
Getters and setters give your classes a clean public API while protecting internal state. They are the idiomatic TypeScript way to add validation, caching, and derived properties without breaking existing callers.