1. ホーム
  2. TypeScript

【TypeScript】get/set アクセサ(ゲッター・セッター)の使い方|プロパティのアクセスに処理を挟む

Share

クラスのプロパティに、単なる値の出し入れではなく「読むときに整形する」「書くときに検証する」といった処理を挟みたくなることがあります。そんなときに使うのがアクセサ(get / set)です。getset を使うと、メソッドとして呼び出すのではなく、obj.fullNameobj.fullName = "..." のように普通のプロパティと同じ書き方のまま、裏側で処理を実行できます。この記事では、ゲッター・セッターの基本の書き方から、算出プロパティやバリデーション、読み取り専用にする方法、TypeScript 特有の型注釈、そして無限ループになりがちなつまずきポイントまでを、動くコードとあわせて解説します。

アクセサ(get / set)とは何か

アクセサは、プロパティの読み取り書き込みに処理を挟むための仕組みです。値を返すときに呼ばれるのが get アクセサ(ゲッター)、値を代入するときに呼ばれるのが set アクセサ(セッター)です。定義はメソッドに似ていますが、使う側は括弧を付けずにプロパティのようにアクセスします。

accessor.ts
class Person {
  private _name = "";

  // get アクセサ:obj.name で読んだときに呼ばれる
  get name() {
    return this._name;
  }

  // set アクセサ:obj.name = "..." で書いたときに呼ばれる
  set name(value: string) {
    this._name = value;
  }
}

const p = new Person();
p.name = "Taro"; // set name が呼ばれる(括弧なし)
console.log(p.name); // get name が呼ばれる → "Taro"

ポイントは、p.name = "Taro"p.name という書き方が、見た目は普通のプロパティ操作なのに、実際にはメソッド(アクセサ)が呼ばれているところです。p.name() のように括弧を付けて呼ぶのではありません。呼び出し側のコードを変えずに、あとから「読み書きに処理を足す」ことができるのがアクセサの利点です。getset の役割を整理すると次のようになります。

アクセサ呼ばれるタイミング役割
get name()obj.name のように読み取るとき返す値を計算・整形して返す。引数は取らない
set name(value)obj.name = x のように書き込むとき代入された値を検証・加工して保存する。引数を1つ取る

なぜアクセサを使うのか

普通のプロパティ(public name: string のようなフィールド)でも値の読み書きはできます。それでもアクセサを使うのは、読み書きのタイミングに処理を差し込めるからです。代表的な用途は、代入される値が正しいかを確かめる検証、返すときに単位や書式を整える整形、複数のフィールドから値を組み立てる算出の3つです。

実際によく使われるのは、private なフィールド(内部データ)を直接公開せず、アクセサ経由でだけ触らせるラップのパターンです。内部の保存用フィールドには #value(本物のプライベートフィールド)や private _value という名前を付け、外向きには get / set を公開します。こうすると、内部の持ち方を変えても外側のインターフェースを保てますし、書き込み時に不正な値をはじくこともできます。

複数のフィールドから値を作る算出プロパティ

ゲッターの典型例が、既存のフィールドから値を計算して返す算出プロパティです。次の例では、firstNamelastName をつなげた fullNameget で返しています。fullName という実体のフィールドは持たず、読まれるたびに組み立てて返すのがポイントです。

full-name.ts
class User {
  constructor(
    public firstName: string,
    public lastName: string,
  ) {}

  // firstName と lastName から組み立てて返す算出プロパティ
  get fullName() {
    return `${this.firstName} ${this.lastName}`;
  }
}

const user = new User("Hanako", "Tanaka");
console.log(user.fullName); // "Hanako Tanaka"

user.firstName = "Yuki";
console.log(user.fullName); // "Yuki Tanaka"(常に最新の値で計算される)

fullName はメソッドではないので user.fullName() ではなく user.fullName で読み取ります。firstName を書き換えたあとに読めば、その時点の値で計算し直されるため、常に最新の結果が得られます。このように「他の値から導ける値」は、フィールドとして二重に持たず、ゲッターで算出するとデータの食い違いが起きません。

set で値を検証して不正な代入をはじく

セッターの得意分野が入力値の検証です。代入されようとしている値を set の中でチェックし、条件を満たさなければ保存しない、あるいはエラーを投げることができます。次の例では、年齢に負の値が入れられたときに throw して代入を拒否しています。

validate.ts
class Account {
  private _age = 0;

  get age() {
    return this._age;
  }

  set age(value: number) {
    // 負の値は不正なのではじく
    if (value < 0) {
      throw new Error("age に負の値は設定できません");
    }
    this._age = value;
  }
}

const account = new Account();
account.age = 20; // OK
console.log(account.age); // 20

account.age = -5; // Error: age に負の値は設定できません

ここでは実際のデータを _age という private フィールドに持ち、外からは age というアクセサ経由でしか触れないようにしています。account.age = -5 のように書くと set age が呼ばれ、条件に反していればエラーになるので、_age に不正な値が入り込みません。検証をセッターに閉じ込めておくと、代入する側は特別なチェックを意識せずに済みます。なお set の中で throw する代わりに、値を丸める(負なら 0 にするなど)といった整形をしても構いません。

set を省いて読み取り専用にする

get だけを定義してセッターを書かなければ、そのプロパティは読み取り専用になります。外から代入しようとすると、TypeScript がコンパイル時にエラーで教えてくれます。先ほどの fullName のような算出プロパティは、そもそも代入できても意味がないので、ゲッターだけにしておくのが自然です。

read-only.ts
class Circle {
  constructor(public radius: number) {}

  // get だけ定義 → 読み取り専用
  get area() {
    return this.radius * this.radius * Math.PI;
  }
}

const c = new Circle(2);
console.log(c.area); // 12.566...

c.area = 100;
// エラー: Cannot assign to 'area' because it is a read-only property.

area にはセッターがないため、c.area = 100 という代入は「読み取り専用プロパティには代入できない」というエラーになります。値を計算して見せるだけで、外から書き換えられたくないプロパティは、この「ゲッターのみ」の形にしておくと安全です。

get と set で型を変える(TypeScript 4.3 以降)

TypeScript 4.3 以降では、同じプロパティの getset異なる型を使えるようになりました。たとえば「読み取るときは必ず string」だけれど「書き込むときは string でも number でも受け付けたい」といった、入り口を広く・出口を厳密にしたいケースで役立ちます。アクセサの引数や戻り値には、通常の変数と同じように型注釈を付けられます。

accessor-types.ts
class Box {
  private _size = 0;

  // 読み取りは number 型で返す
  get size(): number {
    return this._size;
  }

  // 書き込みは number でも string でも受け付ける
  set size(value: number | string) {
    this._size = typeof value === "string" ? Number(value) : value;
  }
}

const box = new Box();
box.size = "42"; // string で代入 → 内部で number に変換
box.size = 10;   // number でも代入できる

const n: number = box.size; // 読み取りは number なので代入できる
console.log(n); // 10

この例では set の引数型を number | stringget の戻り値型を number にしています。そのため書き込み側は文字列でも数値でも渡せる一方、読み取った結果は必ず number として扱えます。ゲッターの戻り値型は、多くの場合は return する値から推論されるため省略できますが、意図をはっきりさせたいときや、セッターと型を分けたいときは明示しておくと読みやすくなります。

interface でアクセサを表す

interface の側では、アクセサかどうかを気にする必要はありません。get / set の両方があるプロパティは通常の読み書き可能なプロパティとして、ゲッターだけのプロパティは readonly を付けたプロパティとして表現します。つまり、実装がアクセサであってもフィールドであっても、インターフェース上の見え方は同じです。

interface-accessor.ts
interface Temperature {
  // get と set の両方がある → 普通のプロパティ
  celsius: number;
  // get だけ(読み取り専用) → readonly プロパティ
  readonly fahrenheit: number;
}

class Thermometer implements Temperature {
  private _celsius = 0;

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

  // 摂氏から華氏を算出(読み取り専用)
  get fahrenheit() {
    return this._celsius * 1.8 + 32;
  }
}

const t: Temperature = new Thermometer();
t.celsius = 25;
console.log(t.fahrenheit); // 77

インターフェース Temperaturecelsius を読み書き可能なプロパティ、fahrenheitreadonly プロパティとして宣言しているだけです。実装クラス側でそれらをアクセサとして用意しても、フィールドとして用意しても、この interface を満たせます。readonly を付けたプロパティに実装側でセッターを用意しないことで、読み取り専用の意図がインターフェースにもそのまま表れます。

無限ループになるとき

アクセサで最もはまりやすいのが、get / set の中で自分自身のアクセサを参照してしまうミスです。保存用のフィールドを用意せず、うっかり同じ名前のプロパティを読み書きすると、アクセサが自分自身を呼び続けて無限ループに陥ります。

infinite-loop.ts
class Bad {
  // 悪い例:get / set の中で自分自身(this.value)を参照している
  get value() {
    return this.value; // ← get value をまた呼ぶ → 無限ループ
  }
  set value(v: number) {
    this.value = v; // ← set value をまた呼ぶ → 無限ループ
  }
}

const bad = new Bad();
bad.value = 1; // RangeError: Maximum call stack size exceeded

get value() の中で this.value と書くと、それは同じゲッターをもう一度呼ぶことになります。セッターも同様で、this.value = v がまた set value を呼び、終わりのない再帰になってスタックがあふれます(Maximum call stack size exceeded)。

解決策は、実際のデータを別名のフィールドに保存することです。アクセサ名(value)とは違う名前、たとえば #valueprivate _value を用意し、アクセサはそのフィールドを読み書きします。こうすればアクセサとフィールドが別物になり、再帰しません。

fixed.ts
class Good {
  // 実データはアクセサとは別名のプライベートフィールドに持つ
  #value = 0;

  get value() {
    return this.#value; // フィールドを読む(アクセサではない)
  }
  set value(v: number) {
    this.#value = v; // フィールドに書く(アクセサではない)
  }
}

const good = new Good();
good.value = 1;
console.log(good.value); // 1(無限ループにならない)

#value は JavaScript / TypeScript の本物のプライベートフィールドで、クラスの外からはアクセスできません。private _value という書き方でも保存先を分けられますが、private は TypeScript のコンパイル時のチェックだけで、実行時には外から _value に触れてしまう点が異なります。実行時にも隠したいなら # を、既存コードとの相性やツールの都合を優先するなら private _ を選ぶとよいでしょう。いずれにせよ、アクセサ名と保存用フィールド名を必ず分けるのが、無限ループを避ける鉄則です。

まとめ

アクセサ(get / set)を使うと、obj.nameobj.name = x という普通のプロパティの書き方のまま、読み書きに検証・整形・算出といった処理を挟めます。他のフィールドから値を組み立てる算出プロパティはゲッターで、代入値のチェックはセッターで書き、set を省けば読み取り専用になります。TypeScript 4.3 以降では getset で型を変えられ、interface ではアクセサを通常のプロパティ(readonly の有無)として表します。そして最大の注意点は、アクセサ内で自分自身を参照すると無限ループになることです。実データは #valueprivate _value のようにアクセサとは別名のフィールドに保存し、アクセサからはそのフィールドを読み書きするようにしましょう。

参考ページ