TypeScript | 関数・クラス・ジェネリクス:クラス設計 – readonlyプロパティの活用

TypeScript TypeScript
スポンサーリンク

ゴール:「この値は“変わらない”」を型で約束できるようになる

readonly プロパティは、
クラス設計でめちゃくちゃコスパのいい武器です。

一言でいうと、

「この値は、作ったあとに書き換えちゃダメ」を
コンパイラに見張らせる仕組み

です。

「変わらない前提の値」と「変わる前提の値」をちゃんと分けられると、
クラスの意味が一気にクリアになります。

順番に、かみ砕いていきます。


基本:readonlyプロパティとは何か

「代入できるタイミングが“コンストラクタの中だけ”になる」

まずは一番シンプルな例から。

class User {
  readonly id: number;
  name: string;

  constructor(id: number, name: string) {
    this.id = id;      // OK(コンストラクタ内)
    this.name = name;
  }

  changeName(newName: string) {
    this.name = newName; // OK
    // this.id = 999;    // エラー:readonly はここでは再代入不可
  }
}

const u = new User(1, "Taro");
// u.id = 2;           // エラー:外からも再代入不可
u.name = "Jiro";       // OK
TypeScript

readonly が付いていると、

  • コンストラクタの中では代入できる
  • それ以外の場所(メソッド内・外部コード)では再代入できない

というルールになります。

ここで重要なのは、

「このプロパティは“生成時に決まって、その後は変わらない”」
という前提を、型で表現している

ということです。

コンストラクタ省略記法+readonly

TypeScript のお約束の省略記法とも相性がいいです。

class User {
  constructor(
    public readonly id: number,
    public name: string
  ) {}
}

const u = new User(1, "Taro");
// u.id = 2; // エラー
u.name = "Jiro"; // OK
TypeScript

public readonly id: number で、

  • プロパティ宣言
  • コンストラクタ引数
  • this.id = id の代入
  • readonly 指定

を一気にやってくれます。


どんなプロパティにreadonlyを付けるべきか

「ビジネス的に変わったら困るもの」

まず真っ先に候補になるのは、
「変わったら意味が破綻するもの」です。

例えば:

  • ID(ユーザーID、注文IDなど)
  • 作成日時(createdAt)
  • 生成時に決まる一意なトークン
  • 「このインスタンスの“性質”」を表すフラグ

など。

class Order {
  constructor(
    public readonly id: number,
    public readonly createdAt: Date,
    public status: "pending" | "paid" | "shipped"
  ) {}
}

const o = new Order(1, new Date(), "pending");
// o.id = 2;          // エラー
// o.createdAt = ...  // エラー
o.status = "paid";    // OK
TypeScript

ここでの設計はこうです。

  • idcreatedAt は「一度決まったら変わらない」
  • status は「状態として変わりうる」

この区別を readonly でハッキリさせることで、
「何が変わってよくて、何が変わっちゃダメか」がコードから伝わります。

「変えられるとバグの温床になるもの」

もう一歩踏み込むと、

「変えられても技術的には動くけど、
変えられるとバグの温床になるもの」

readonly 候補です。

例えば、内部的に使う「設定値」や「定数的な情報」。

class Pagination {
  constructor(
    public readonly pageSize: number,
    public currentPage: number = 1
  ) {}

  next() {
    this.currentPage += 1;
  }
}
TypeScript

pageSize を途中で変えられると、
「どのページからどの件数を取るのか」が一気に怪しくなります。

「この値、途中で変えられたらロジックが破綻するな」と感じたら、
迷わず readonly を付けていいです。


readonlyと「不変オブジェクト」の感覚

プロパティ自体はreadonlyでも、中身は変えられることがある

ここで一つ、よくハマるポイントがあります。

class Team {
  readonly members: string[];

  constructor(members: string[]) {
    this.members = members;
  }
}

const t = new Team(["Taro", "Hanako"]);
t.members.push("Jiro"); // これはOK
TypeScript

members プロパティは readonly ですが、
その中身(配列の要素)は変えられます。

readonly が守っているのは「プロパティへの再代入」であって、
「中身のオブジェクトの変更」ではないからです。

つまり、

  • t.members = [] はダメ(プロパティの再代入)
  • t.members.push(...) はOK(中身の変更)

ということになります。

「中身も含めて変えさせたくない」なら、別の工夫が必要

本当に「中身も含めて変えさせたくない」なら、
いくつか選択肢があります。

例えば、コピーを返す:

class Team {
  private readonly _members: string[];

  constructor(members: string[]) {
    this._members = [...members];
  }

  get members(): string[] {
    return [...this._members]; // コピーを返す
  }
}

const t = new Team(["Taro", "Hanako"]);
const ms = t.members;
ms.push("Jiro"); // Team 内部の配列は変わらない
TypeScript

ここでは、

  • 内部の配列 _membersprivate readonly
  • 外からは get members() でコピーだけ見せる

という設計にしています。

「この配列、外からいじられたら絶対困る」というときは、
readonly だけでなく、
「中身を直接触らせない」という設計もセットで考えるといいです。


readonlyとコンストラクタ設計のセット使い

「生成時に必須+その後不変」の黄金パターン

readonly が一番気持ちよくハマるのは、

「コンストラクタで必ず渡させる」+「その後は変えさせない」

という組み合わせです。

class User {
  constructor(
    public readonly id: number,
    public readonly createdAt: Date,
    public name: string
  ) {}
}
TypeScript

この設計だと、

  • idcreatedAt は「インスタンスが存在するなら必ずある」
  • かつ「途中で変わらない」

という強い前提が生まれます。

この前提があると、
他のメソッドの実装もシンプルになります。

class User {
  // さっきと同じ

  getDisplayLabel(): string {
    // id / createdAt が「必ずある」「変わらない」前提で書ける
    return `${this.id} (${this.createdAt.toISOString()}) ${this.name}`;
  }
}
TypeScript

「この値は、生成時に必ず決まっていて、その後も変わらない」
と思ったら、
コンストラクタ必須引数+readonly をセットで使う、
と覚えておくといいです。


readonlyとpublic / privateの組み合わせ

public readonly:外から“見えるけど変えられない”

class User {
  constructor(
    public readonly id: number,
    public name: string
  ) {}
}

const u = new User(1, "Taro");
console.log(u.id); // 見える
// u.id = 2;       // でも変えられない
TypeScript

public readonly は、

  • 「外から見える」
  • 「外からも中からも再代入できない(コンストラクタ以外)」

という組み合わせです。

「ID は外から参照されるけど、誰にも書き換えさせたくない」
みたいなときにピッタリです。

private readonly:中からも“定数扱い”

class Service {
  private readonly baseUrl: string;

  constructor(baseUrl: string) {
    this.baseUrl = baseUrl;
  }

  fetch(path: string) {
    return fetch(this.baseUrl + path);
  }
}
TypeScript

private readonly は、

  • 外から見えない
  • 中からも再代入できない

という、完全に「内部定数」的な扱いになります。

「このクラスの内部設定として固定したい値」は、
private readonly にしておくと安心です。


まとめ:readonlyプロパティの活用を自分の言葉で整理すると

最後に、あなた自身の言葉でこう整理してみてください。

  • readonly は「コンストラクタ以外で再代入できなくする」スイッチ
  • 「生成時に決まって、その後変わらない前提の値」に付けると強い
  • ID・作成日時・設定値・性質フラグなどは積極的に readonly 候補
  • public readonly は「外から見えるけど変えられない」
  • private readonly は「クラス内部の定数」
  • 配列やオブジェクトに付けたときは「プロパティの再代入は防げるが、中身の変更は別問題」と理解しておく

今書いているクラスを1つ開いて、
プロパティを上から眺めながら、

「これ、本当は途中で変わってほしくないよな?」
「これ、readonly にしたらロジックがスッキリしない?」

と一つずつ問い直してみてください。

readonly をちゃんと使い始めると、
クラスが「なんとなく動くもの」から、
“ルールが型に刻まれた、信頼できる設計図” に変わっていきます。

タイトルとURLをコピーしました