TypeScript Getters and Setters
Getters and setters are special class members that let you run code whenever a property is read or written, while the caller still uses ordinary property syntax like obj.value instead of calling a method. In TypeScript they are fully typed, so the compiler checks the return type of a getter and the parameter type of a setter just like any other class member. They matter because they let you validate input, compute derived values on the fly, and hide internal implementation details behind a clean, property-like public API.
Overview / How It Works
TypeScript’s get and set accessors are built directly on top of JavaScript’s native accessor properties (the same mechanism behind Object.defineProperty with get/set descriptors). TypeScript does not invent new runtime behavior here — it adds compile-time typing on top of a feature JavaScript already has. A get x() method and a set x(value) method with the same name form a single accessor pair. From the outside, callers see one member named x: reading instance.x invokes the getter, and writing instance.x = something invokes the setter. Neither is called with parentheses, even though both are technically functions under the hood.
You can define a getter without a setter, which makes the property effectively read-only from outside the class — TypeScript will produce a compile error if any external code tries to assign to it. You can also define a setter without a getter, creating a write-only property, though that pattern is rare. Since TypeScript 4.3, the getter’s return type and the setter’s parameter type are allowed to differ (useful for cases like accepting a loose input type but always returning a normalized one), but in the vast majority of real-world code you’ll want them to match so the property behaves symmetrically.
Accessors can be marked public, private, protected, or static, and they participate in class inheritance and interface implementation just like regular properties: a class can satisfy an interface’s plain property requirement using either a real field or a get/set pair, because interfaces only describe the type shape, not how it is implemented.
Syntax
class ClassName {
get propertyName(): ReturnType {
// compute and return a value
}
set propertyName(value: ParamType) {
// validate/store the incoming value
}
}
- get propertyName() — defines how reading
instance.propertyNamebehaves. Must return a value matchingReturnTypeand takes no parameters. - set propertyName(value) — defines how writing
instance.propertyName = xbehaves. Must take exactly one parameter. - Matching name — the getter and setter must share the same identifier to be treated as one accessor pair.
- Access modifiers —
public,private,protected, orstaticcan prefix either accessor (both are usually given the same visibility). - No parentheses at the call site — accessors are used exactly like fields:
instance.propertyName, neverinstance.propertyName().
Examples
Example 1: A computed, two-way accessor (Temperature)
class Temperature {
private _celsius: number;
constructor(celsius: number) {
this._celsius = celsius;
}
get celsius(): number {
return this._celsius;
}
set celsius(value: number) {
this._celsius = value;
}
get fahrenheit(): number {
return (this._celsius * 9) / 5 + 32;
}
set fahrenheit(value: number) {
this._celsius = ((value - 32) * 5) / 9;
}
}
const temp = new Temperature(25);
console.log(temp.celsius);
console.log(temp.fahrenheit);
temp.fahrenheit = 98.6;
console.log(temp.celsius.toFixed(1));
Output:
25
77
37.0
Here _celsius is the single source of truth, stored as a private field. fahrenheit is not stored at all — it’s computed from _celsius every time it’s read, and its setter reverses the conversion to update _celsius. Two accessors can stay perfectly in sync because they share one backing field.
Example 2: Validating input in a setter (BankAccount)
class BankAccount {
#balance: number;
constructor(initialBalance: number) {
if (initialBalance < 0) {
throw new Error("Initial balance cannot be negative");
}
this.#balance = initialBalance;
}
get balance(): number {
return this.#balance;
}
set balance(amount: number) {
if (amount < 0) {
throw new Error("Balance cannot be negative");
}
this.#balance = amount;
}
}
const account = new BankAccount(100);
console.log(account.balance);
account.balance = 250;
console.log(account.balance);
try {
account.balance = -50;
} catch (error) {
if (error instanceof Error) {
console.log(error.message);
}
}
Output:
100
250
Balance cannot be negative
This is the classic reason to reach for a setter: #balance (a true private field) can never be set directly from outside the class, so every write is forced through set balance, which enforces the invariant that balances stay non-negative. Note that error in the catch block is typed unknown under strict, so it must be narrowed with instanceof Error before accessing .message.
Example 3: A read-only computed property (Person)
class Person {
private firstName: string;
private lastName: string;
constructor(firstName: string, lastName: string) {
this.firstName = firstName;
this.lastName = lastName;
}
get fullName(): string {
return `${this.firstName} ${this.lastName}`;
}
}
const person = new Person("Ada", "Lovelace");
console.log(person.fullName);
Output:
Ada Lovelace
Because there is no set fullName, fullName becomes a read-only property from the outside — TypeScript will reject any attempt to assign to it, which is exactly what the next section demonstrates.
Under the Hood
When the compiler sees instance.propertyName, it looks up the class's accessor declarations rather than treating it as a plain field lookup. On a read, it finds get propertyName() and type-checks the expression against that getter's return type. On a write, it looks specifically for a set propertyName(value); if none exists, the assignment is rejected at compile time with a read-only error, even though nothing would technically stop you from adding a new property at runtime in plain JavaScript. If a setter does exist, TypeScript checks that the assigned value is assignable to the setter's parameter type, just as it would for a function argument.
Once compiled to JavaScript, all of this type information is erased. The get/set keywords compile straight through to native ECMAScript accessor syntax (or, for older targets, to Object.defineProperty calls), and any backing field like _celsius or #balance becomes an ordinary instance property in the emitted JS with no trace of its original type annotations. In other words, TypeScript's entire contribution to getters and setters is compile-time verification — catching a misuse (like assigning a string to a numeric setter) before the code ever runs, rather than adding any new runtime mechanism.
Common Mistakes
Mistake 1: Assigning to a getter-only property
class Person {
private firstName: string;
private lastName: string;
constructor(firstName: string, lastName: string) {
this.firstName = firstName;
this.lastName = lastName;
}
get fullName(): string {
return `${this.firstName} ${this.lastName}`;
}
}
const person = new Person("Ada", "Lovelace");
person.fullName = "Grace Hopper";
Since fullName only has a getter, tsc reports: Cannot assign to 'fullName' because it is a read-only property. The fix is to add a matching setter that decomposes the incoming value back into the backing fields:
class Person {
private firstName: string;
private lastName: string;
constructor(firstName: string, lastName: string) {
this.firstName = firstName;
this.lastName = lastName;
}
get fullName(): string {
return `${this.firstName} ${this.lastName}`;
}
set fullName(name: string) {
const [first, last] = name.split(" ");
this.firstName = first;
this.lastName = last;
}
}
const person = new Person("Ada", "Lovelace");
person.fullName = "Grace Hopper";
console.log(person.fullName);
Output:
Grace Hopper
Mistake 2: Calling an accessor like a method
class Circle {
constructor(private radius: number) {}
get area(): number {
return Math.PI * this.radius ** 2;
}
}
const circle = new Circle(5);
console.log(circle.area());
Because area is an accessor, circle.area already evaluates to a number — calling it with () tries to invoke a number as a function. tsc reports: This expression is not callable. Type 'Number' has no call signatures. Drop the parentheses and access it as a property:
class Circle {
constructor(private radius: number) {}
get area(): number {
return Math.PI * this.radius ** 2;
}
}
const circle = new Circle(5);
console.log(circle.area);
Output:
78.53981633974483
Best Practices
- Give the backing field a different name than the accessor (
_celsius,#balance) — naming the field the same as the getter causes infinite recursion. - Put validation and normalization logic in the setter so invalid state can never be constructed, instead of trusting callers to sanitize their own data.
- Expose computed, derived, or sensitive values through a getter-only accessor to make them read-only by construction.
- Keep getters cheap and side-effect-free; callers expect property access to be instant, not to trigger network calls or heavy computation.
- Don't reach for accessors when a plain public field would do — if there's no validation or computation happening, a field is simpler and equally type-safe.
- When two accessors share state (like
celsius/fahrenheit), keep exactly one field as the source of truth to avoid values drifting out of sync.
Practice Exercises
- Write a
Rectangleclass with privatewidthandheightfields, a constructor, and aget area(): numberaccessor that returnswidth * height. - Write a
Passwordclass whoseset value(password: string)throws anErrorif the password is shorter than 8 characters, plus aget isSet(): booleanthat reports whether a valid password has been stored. - Write a
Distanceclass that stores meters internally, with aget feet/set feetaccessor pair that converts between meters and feet (1 meter ≈ 3.281 feet).
Summary
- Getters (
get) and setters (set) let a class member be accessed with plain property syntax while running code on every read or write. - A getter with no setter creates a read-only property; TypeScript rejects assignments to it at compile time.
- Setters are the natural place to validate or normalize incoming values before they're stored.
- Accessors are typed like any class member — the getter's return type and setter's parameter type are checked by the compiler.
- At runtime, accessors compile to native JavaScript accessor properties; all TypeScript type annotations are erased and add no runtime overhead.
- Never call an accessor with parentheses, and never name a backing field the same as its accessor.
