📒

逆参照にならず、データ定義をリーダブルにする getter(TypeScript版)

2025/03/10に公開

データの定義にジャンプできる機能があってもその内容が分からない問題

Visual Studio Code などの開発環境には、関数の定義にジャンプする機能があります。 Ctrl キー(mac なら command キー)を押しながら、関数呼び出しをするコードにある関数名をクリックすることで、関数定義が書かれたコードにジャンプすることができます。 関数定義のコードを見れば、その関数がどういった処理をしているのかが分かります。

function  main() {
    console.log(getUser("John"));
        // Ctrl キー を押しながら getUser をクリックすると定義にジャンプします
}

function  getUser(name: string): User {  // ここにジャンプします
    return  {name: name, point: 100};  // オブジェクトを生成して返します
}

オブジェクトの型やプロパティ(属性、データ)の定義にジャンプすることもできます。 しかし、関数と違ってどういったデータなのかはデータの定義のコードに書かれていません

function  main() {
    const  user: User = {name: name, point: 100};
    console.log(user.point);
        // Ctrl キー を押しながら point をクリックすると定義にジャンプします
}

interface  User {
    name: string;
    point: number;  // ポイント数(ToDo:ポイント数とは?)  // ここにジャンプします
}

データに対して開発者が必要とする情報は、そのデータがどのデータベースやどのサーバーから取得したデータであるか、どのような演算を行なった結果のデータであるかといった情報です。 データの定義にジャンプしたら、それらの情報がわかるようにするにはどうしたらいいでしょうか。 1つは、開発環境にある参照元にジャンプする機能です。 しかし、ほとんどの場合、参照元のコードは数多くあるため、どの参照元を見れば目的の情報が得られるかを探さなければなりません。 見つかってもまた更に参照元のコードを探さなければならないこともよくあります。 そうしているうちに何のために情報を探していたかも忘れてしまうでしょう。

本記事では、データの定義へジャンプしたときに、どういったデータであるかを知ることができるようなコードの書き方を説明します。 ざっくりいうと、データ プロパティ(変数)の代わりに getter(またはメソッド)でプロパティのコードを書くことですが、どういうことなのか詳しく説明していきます。

  • データ プロパティ に代入すると逆参照になってしまう現象の説明
  • getter を使うことでどういったデータなのかが分かるとはどういうことかの説明
  • getter を使っても処理速度が遅くならないためのメモ化の説明

また、getter に関して次の説明もしています。

  • getter を使う目的
  • 互換性のための getter
  • getter の粒度
  • ドメイン-インフラのレイヤー構成と getter

オブジェクト リテラル と interface のおさらい(TypeScript)

完全なコード https://github.com/Takakiriy/Trials/blob/master/try_RelationalGetter/src/accessor0.ts

本題に入る前に、JavaScript/TypeScript のオブジェクトやクラスについておさらいしておきましょう。
JavaScript では、オブジェクト リテラル を使ってデータ構造を簡単に書くことができます。 オブジェクト リテラル だけでは型の違い(何のクラスやインターフェースであるかなど)の内部データを持っていません。 number 型や string 型ではなく、object 型であるというだけです。

💡 オブジェクト リテラル は Object.prototype から継承している Object 型のインスタンスです。 なお、Object 型は number や string なども含みますが、object 型はそれらを含まない TypeScript だけの型です。

const  user = {
    firstName: "John",
    lastName: "Doe",
};

console.log(user.firstName);

一方、TypeScript では、オブジェクト リテラル などに対してインターフェース型を定義することができます。 型の名前を適切に付けることでどういったデータ構造であるかがコーディング時に分かりやすくなります。 インターフェース型を定義すると、コーディング時にプロパティが不足していればエラーがすぐに表示されます。 ビルドするまで、または実行時に実際にそのコードが実行されるまで気づかないといったことが無くなり、開発効率が上がり、品質も上がります。

interface  UserData {
    firstName: string;
    lastName: string;
}

const  user: UserData = {
    firstName: "John",
    lastName: "Doe",
}

console.log(user.firstName);

getter を持つオブジェクトの定義サンプル

完全なコード https://github.com/Takakiriy/Trials/blob/master/try_RelationalGetter/src/accessor1.ts

getter を持つクラスは、以下のように書きます。

// getter の基本 - fullName getter
// 定義例
interface  UserData {
    firstName: string;
    lastName: string;
}
class  User implements UserData {
    // プロパティ一覧:firstName, lastName, fullName
    firstName: string = "";
    lastName: string = "";
    get  fullName(): string {
        return `${this.firstName} ${this.lastName}`;
    }
    constructor(data: UserData) {
        Object.assign(this, data);
    }
}

// 使用例
function  main1() {
    const  user = new User({
        firstName: 'John',
        lastName: 'Doe',
    });

    console.log(user.firstName);  // "John"
    console.log(user.fullName);   // "John Doe"
}
main1();

プロパティ一覧のコメントは説明のために書いてありますが、通常は書きません。 更新されずに誤った情報になる可能性が高いからです。

interfaceclass の両方に変数のプロパティを書く必要があります。

interface は getter を含まない オブジェクト リテラル に一致する型として、class は getter を持つ型として定義します。 getter 以外の データ プロパティ に違いは無いので 2回も書くのは冗長な気がしますが、JavaScript 的には違いがあるため書く必要があります。 オブジェクト リテラル を使ってオブジェクトを初期化しないのなら interface は不要ですが、多くの場合で必要でしょう。

Object.assign に指定している thisdata は別のオブジェクトです。 shallow copy をします。 なお、shallow copy は一般にスプレッド構文を使いますが、this にはスプレッド構文を使うことができません。

getter を書くときの注意点

getter があるオブジェクトが、なぜこのような書き方になるかについて補足します。

TypeScript の interface の中に getter の定義内容を書くことはできません。

💡 getter の定義内容(を指すアドレス)が実体のある内部データなのですが、interface はデータを持てないので interface の中に getter の定義内容を書くことはできません。 内部データとして実行中のメモリーのどこにも割り当てられないからです。 C++ の virtual ではないメソッドならその内部データが不要なのですが、TypeScript ではそれはサポートされていません。

interface  UserData {
    firstName: string;
    lastName: string;
    get  fullName(): string {  // ❌エラー。interface の中に getter は書けません
        return `${this.firstName} ${this.lastName}`;
    }
}

class の中であれば getter 書くことができます。 しかし、getter を持つクラスの変数に オブジェクト リテラル を代入することはできません

💡 オブジェクト リテラル は Object.prototype から継承したインスタンスであり、class で書いたクラスは Object.prototype のサブクラスであり、スーパークラスのインスタンスはサブクラスの変数に代入できないので、オブジェクト リテラル を「getter というプロパティを持つクラスの変数」に代入することはできません。 厳密にはクラスの継承関係ではなく、「構造的型付け」による必要なプロパティの構成(名前と型)を持っていれば代入はできますが、getter が構造的型付けでの判定に関わるプロパティの 1つとして使われる仕様であるため、getter が不足している オブジェクト リテラル を、getter を持つクラスに代入することはできません。

class  User {
    ...
    get  fullName(): string {  // getter
        return `${this.firstName} ${this.lastName}`;
    }
}

const  user: User = {  // ❌エラー。 fullName getter が不足しているので代入できません
    firstName: "John",
    lastName: "Doe",
}

変数へ代入することで逆参照になるコード

完全なコード https://github.com/Takakiriy/Trials/blob/master/try_RelationalGetter/src/appAttributes.ts

ここからが本題です。 まずは大変ですが、意味を調べにくい逆参照のコードを追っていくことにしましょう。 途中でコードを追うのが嫌になったら、この章は斜め読みだけでも構いませんが、レガシーなプロジェクトを引き継いだ保守担当者は、すでにあるものを少し修正するだけの簡単な仕事をしているのではなく、前担当者の設計思想をコードから読み取る苦行をした上でその思想に沿って修正することを強いられることを理解してください。

下記の point プロパティの意味を調べるために、Ctrl キー を押しながら point をクリックしてみましょう。

getUser("John").point

すると、User インターフェース の中の point にジャンプしました。 point が number 型であることと、User インターフェースに所属していることは分かりました。 しかし、それ以上のことは分かりません。 困りました。

interface  User {
    name: string;
    point: number;  // ここにジャンプします
}

次は、getUser の返り値が何であるか調べるために、Ctrl キー を押しながら getUser をクリックしてみましょう。

以下では、他に関連するコードも示します。

function  getUser(name: string): User {
    const  input = getInputParameters(name);
    return  {name: name, point: input.userPointRecord.point};
}

function  getInputParameters(name: string): InputRecords {
    const  userRecord = getUserRecord(name)
    return  {
        userRecord: userRecord,
        userPointRecord: getPointRecord(userRecord.id),
    };
}

interface  InputRecords {
    userRecord: UserRecord;
    userPointRecord: PointRecord;
}

function  getPointRecord(userId: number): PointRecord {
    const  records: {[userId: number]: PointRecord} = {}
    records[1] = {userId: 1, point: 110};
    records[2] = {userId: 2, point: 120};
    return  records[userId];
}

getUser 関数の return から、point は input.userPointRecord.point であることが分かりました。

return  {name: name, point: input.userPointRecord.point};

今回は、getUser の中を探したことで分かりましたが、処理に注目するということは point 以外の様々なコードを読んでいき、関係あるかないかを判断しながら見つけていくので、とても大変です。

最初に point をクリックしたときにここにジャンプできれば分かりやすかったかもしれません。 しかし、ここは point 変数の定義ではから逆参照の関係があるコード部分なので、開発環境にいくら参照を一覧できる機能があったとしても、一覧されたコードの断片がどういうコードなのかを理解しなければ、目的のコードであるかを判定できず、大変です。

userPointRecord は何でしょうか。 定義にジャンプすると、InputRecords インターフェースにジャンプしました。 同様にプロパティの型と InputRecords インターフェイスに所属していることだけが分かり、それ以上のことは分かりません。 困りました。 なぜこうなったかというと、これも逆参照だからです。 値を代入するコードは変数の定義ではなく、変数を参照しているコードと見なされてしまうのです。

interface  InputRecords {
    userRecord: UserRecord;
    userPointRecord: PointRecord;  // ここにジャンプします
}

💡 変数に代入することは必然的に逆参照になります。 逆参照になっているコードは定義のジャンプ先にはなりません。

続けます。 input が getInputParameters の返り値であることが分かりました。 input の定義にジャンプすれは分かります。 クラスに所属しないローカル変数なら逆参照にはなりません。

const  input = getInputParameters(name);

getInputParameters の定義にジャンプしたら、userPointRecord は getPointRecord の返り値であることが分かりました。

userPointRecord: getPointRecord(userRecord.id),

このように処理を追っていくことで調べていくことになりますが、今回のサンプルには含まれていませんが、実際にはデータの説明とは関係ないコードを読んでいくことになりますし、userPointRecord が、もっと深いところで代入されていることもよくあるので、本当に大変です。

getPointRecord の定義から point の値が 110 または 120 であることが分かりました。 getPointRecord は、データベースからレコードをリードする処理に相当する関数ですが、このサンプルでは実際のデータベースである必要はないので代わりに辞書を使っています。

records[1] = {userId: 1, point: 110};
records[2] = {userId: 2, point: 120};

逆参照を追うことが、かなり大変であることがお分かりいただけたでしょうか。

getter を書いてデータの意味を調べやすくする

完全なコード https://github.com/Takakiriy/Trials/blob/master/try_RelationalGetter/src/appGetters.ts

TypeScript Playground ← 動かせます!

次に、getter を使ったコードを追っていきましょう。

下記の point プロパティの意味を調べるために、Ctrl キー を押しながら point をクリックしてみましょう。

getUser("John").point

すると、User クラスの中の point getter にジャンプしました。 point が this.userPointRecord.point であることがすぐに分かりました。 getter を使わない逆参照のコードでは getUser 関数を回り道しなければこの情報まで辿り着けませんでした。

class  User implements UserData {
    get  point(): number {
        return  this.userPointRecord.point;

次に、userPointRecord の定義にジャンプしてみると、getPointRecord 関数を呼び出していることが分かりました。

class  User implements UserData {
    get  userPointRecord(): PointRecord {
        return  getPointRecord(this.userRecord.id);

逆参照のコードの最終到達点として、getPointRecord がデータベースからレコードをリードする処理に相当する関数であると説明しましたが、もう到達してしまいました。

records[1] = {userId: 1, point: 110};
records[2] = {userId: 2, point: 120};

なお、getPointRecord を呼び出す前に this.userRecord.id が先に評価(実行)されます。 userRecord の定義にジャンプしてみましょう。 すると、 getUserRecord という別のテーブルのレコードをリードする処理に相当する関数を呼び出していることがすぐに分かりました。

class  User implements UserData {
    get  userRecord(): UserRecord {
        return  getUserRecord(this.name);

たった 3回 getter の定義にジャンプしただけで、何も探す手間をかけることなく、ここまで情報が得られるのは、すごくないですか。

何度もアクセスして遅くならないためのメモ化

完全なコード https://github.com/Takakiriy/Trials/blob/master/try_RelationalGetter/src/accessor2.ts

データ プロパティ に代入するコードは、データベースへのアクセスやサーバーへのアクセスの処理時間があまり長くならないことが予測可能ですが、getter を使うコードは、何も工夫しなければ、プリミティブ型の getter にアクセスするたびにデータベースへやサーバーへのアクセスが発生してしまい、非常に遅くなります。 それを避けるのがメモ化です。

💡 データ プロパティ に代入するコードでも、アーキテクチャにこだわって類似のオブジェクトを多層化していくと意図しない冗長なアクセスが増えて処理内容や処理時間が予測不能になっていき、メモ化が必要になっていきます。

メモ化はキャッシュの一種で、関数の出力が同じになるときに計算やアクセスをしないで、メモ(キャッシュ)に書いておいた前回の出力を返すだけの処理を行い、処理を高速化します。 データベースへやサーバーへアクセスしないので速くなります。 ただし、何MBものファイルになるようなデータをメモ化するとメモリーの使用量が問題になるかもしれません。 また、関数の入力が変わって出力が変わる状況になったら、メモを破棄する必要があります。

以下のサンプルでは accessTime getter をメモ化しています。 メモにあたる _accessTime データ プロパティ の値が undefined だったら値を得るための処理を行いますが、undefined ではなかったら _accessTime データ プロパティ の値を返すだけの処理を行います。 出力内容が変わる状況になったら clearMemo メソッド を呼び出してメモを破棄します。 すべてのインスタンスに対して破棄する必要があるので、メモを持つインスタンスの管理も必要になります。

class  User implements UserData {
    get  accessTime(): Date {
        if (this._accessTime === undefined) {

            const  accessTime = new Date();
            this._accessTime = accessTime;
        }
        return  this._accessTime;
    }
    clearMemo(): void {
        this._accessTime = undefined;
    }
    _accessTime?: Date;

💡 このような _accessTime データ プロパティ をメモとする実装は実装方法の 1つです。 インスタンスのすべての データ プロパティ をメモ化するライブラリを開発して使う方法も考えられます。

getter を書く目的

改めて getter を書く目的を挙げると、以下のとおりです。

  • 計算)他のプロパティから目的のプロパティを計算できるときに、データ プロパティ が冗長になることを防ぐ
  • 互換性)データの互換性の維持やバージョン移行期間のために、古い名前と新しい名前を共存させる。また、新旧を抽象化したプロパティを追加する
  • 構造化)プロパティの存在を明示して、データ構造に沿って整理する

特にプログラムが大きくなるほど、レガシー コード を改修するときほど、データ構造に沿って整理されていることが有用になるので、getter に書くことを最優先にします。

互換性のための getter

バージョンが上がったときにテーブルが変わったら、以下のように書けばバージョンで違いでどう違うかが明確になります。

class  User {
    get  point(): number {
        if (this.version >= 2) {
            return  this.userPointRecord.point;
        } else {
            return  this.accoutPointRecord.point
        }

インターフェース継承が好きな人は、バージョンが上がったときの互換性を保つときにインターフェース継承をする構成にする人もいるでしょう。 実装するクラスが未知数だったり内容が全く違ったりするのならインターフェースでいいです。 しかし、通常の互換性が目的であれば種類は既知なので、getter の中でバージョンを判定するほうがインターフェースより可読性が高いです。

もし、バージョンをクラスにしてインターフェースで互換性を持たせようとすると、バージョン間の関係性が暗黙的になり、分かりにくくなります。 以下のように並べれば多少は分かりやすくなりますが、それでもこのように並べる作業をしなければなりません。

class  UserVer3 implements User{
    get  point(): number {
        return  this.userPointRecord.point;

class  UserVer2 implements User{
    get  point(): number {
        return  this.userPointRecord.point;

class  UserVer1 implements User{
    get  point(): number {
        return  this.accoutPointRecord.point

💡 インターフェースが将来にわたって変わらない保証は全くありません。 移植レイヤーとしてわざわざ別途用意したインターフェースのはずなのに内容が変わってしまった経験は本当に数多くあります。 ただ、バージョン違いでも メジャー バージョン で実装を一新したぐらい内容が殆ど違うものならクラスで分けるのが良いでしょう。 バージョンが違うだけのクラスに共通するインターフェースは作りますが、バージョンが上がるたびにそのインターフェースは変わるでしょう。

💡 昔は多態性(ポリモーフィズム)を書くのが正しく、if/switch 文を書くのはダサいと言われていましたが、今はそんなことはありません。 暗黙的になることが便利でスマートだとイキる人が多かったのでしょう。 ドメインの観点での書かれた文章に沿うようにコードを書くべきです。 文書の中の一部のデータの説明にバージョンの差が書いてあれば getter 内にコーティングし、バージョンが違うと丸ごと文書が変わるようであれば、クラスにコーティングします。

💡 開発環境において、多態性(ポリモーフィズム)のコードは、定義へジャンプすることができないまたは期待通りに動かないことがよくあるため、開発効率が下がります。 また、定義が一覧されることがあり、一覧から選ぶのも難しいことが多いです。 バージョン番号がクラス名にあるなど違いが明確なら選ぶのは簡単です。

オブジェクトの粒度の getter

完全なコード

本記事の最初の getter のサンプルでは string 型や number 型などのプリミティブのプロパティの getter を書いて説明しましたが、粒度が荒いオブジェクトの getter を書くだけでも有用です。

オブジェクトの getter(書く):

  • userRecord
  • userPointRecord

プリミティブの getter(書かなくてもよい):

  • User クラスの point

ただし、プリミティブの getter を書かなくてもよいとなるには、条件があります。

新規開発の場合、データベースのテーブルとドメイン(DDDのドメイン)がだいたい一致し、サーバーから得られる情報の構成を変えることがあまりないので、オブジェクトの粒度(プリミティブより粗い粒度)の getter だけを書いて、アプリケーションからは データ プロパティ に直接アクセスしても問題ありません。 なぜなら、テーブルにアクセスしていても、ドメインと同じ名前や構成だからです。

バージョンが上がってドメインの構造とテーブルのデータ構造が一致しなくなってきたり、1つの Web ページにいくつものテーブルを混合した情報を扱うようになってきたりすると、プリミティブのプロパティごとに getter を書く必要が出てきいます。

ただ、そのような要求が実際に発生するまでは、「早すぎる対応」すなわちプリミティブの getter を書くことは、しない方が良いです。

💡 テーブルや、API の JSON に対応する Facade パターンも不要です。 getter や サンプル コード を書けば Facade の目的は満たせます。

ドメイン-インフラのレイヤー構成と getter

完全なコード

(上記の完全なコードは、これまでのコードをファイルで分けただけで内容は変わりません)

アプリケーションの核といえる ビジネス ロジック などのドメインのロジックを表現するコードは getter には適しません。 ロジックの出力や HTTP レスポンス が 1つのオブジェクトになっていることはよくありますが、それが何かのドメインのエンティティ(概念)を表しているわけではなく、出力を単に集めただけだからです。

ロジックは関数で(または static メソッド、または クラス メソッドで)書きます。 ドメインの仕様書の主な内容に書かれることがロジックにあたります。 GUI/Web フレームワークからコールバックされるメソッドから関数に分けると、テストがしやすくなります。 分類のために今までクラスを使っていたのなら名前空間に変えましょう。

ロジックが扱うデータはプロパティで書きます。 オブジェクトの データ プロパティ や getter を使ってドメインの概念に対応させるように書きます。 基本的に、画面上の項目として存在するものをそのままエンティティ(概念)にします。 ドメインの仕様書の用語一覧に書かれるようなものがロジックが対象とするデータにあたります。 getter は同じクラス内のプロパティしか入力することができないので、Infra.ts には書ける場面が少ないですが、データベースやサーバーを複数扱う親側のレイヤー EntityDomain.ts であれば書ける場面が多くあります。

💡 レイヤーで分けると import の循環参照エラーに苦しめられることがあります。基本は出力に近いものに import 文を書き、その import 文の引数には入力に近いものを指します。 もしくは、クラスの実装に import 文を書き、その import 文の引数には インターフェースを指します。 何層にもなったこだわりのアーキテクチャーを採用すると、import の循環参照エラーによく遭遇するので、そういったアーキテクチャーを使うのはやめてフラットでシンプルにしましょう。

💡 プロパティごとの getter に対応するとき、中間層に位置する抽象的なデータ構造や、facade パターンや DTO のクラスを経由するような設計をするのは悪手です。 開発者が中間レイヤーに設計したオレオレ汎用抽象データ構造にしないことです。 オレオレ汎用抽象データ構造は将来の変更を考慮して汎用的に抽象化されているから便利で互換性に強く作られている、なぜならコードがそう考えて作られているから、と考えますが、他の開発者や運用者は、100%流用しません。 オレオレ汎用抽象データ構造にはドキュメントが存在しませんし、生のデータを多くの人が見ているデータベースに比べれば圧倒的にサンプルが少ないので、オレオレ汎用抽象データ構造を理解するきっかけすらありません。 DTO はクラスではなく関数の内部データにして、グローバルなデータ構造と切り離します。 あくまでドメインやページの構成と IT インフラ の1対1のフラットな関係を維持すべきです。 オレオレ汎用抽象データ構造を含む レガシー コード への対応方法については、また機会があれば説明したいと思います。

ロジックのテスト

テスト コード を書くときに重要なことは、

  • ロジックの視点で、あらゆるユースケースを網羅すること
  • 入力データがあるデータベース等にアクセスする手前のコードまで動かすこと(アクセス部分からモックに変えること)

ユースケースを網羅することは、顧客と合意している仕様なのでテストするのは当然ですね。

getter 単体やデータベースにアクセスする部分などの ユニット テスト で対象となるコードは、上記のテストで通るので、そのような基本的な ユニット テスト は書く必要ありません。 オレオレ汎用抽象データ構造が正しく動くことのテストなんかは最悪です。 書いてしまうと仕様が変わってテストが失敗したときに何が正解か分からず技術的負債になってしまいます。 テスト コード が少ないことも技術的負債と言うようですが、そのテストが通っても不具合が出てしまう意味のない ユニット テスト も実質 テスト コード が存在していないも同然です。

ユニット テスト に書ける粒度の細かい仕様が外部仕様として存在している場合だけ、ユニット テスト を書きます。

(参考)データ構造と コール ツリー

サンプル コード のデータ構造と コール ツリー を示します。 なお、テキスト エディター で書ける、データ構造と コール ツリー の書き方は、大規模プログラムの理解を助ける図を VSCode で素早く書く方法 〜 構造化ドキュメンテーション (3)で詳しく説明しています。

データ構造:

User:
    # .userRecord  UserRecord
    UserRecord:
        # .id    number
        # .name  string
    # .point  number
    # .userPointRecord  PointRecord
    PointRecord:
        # .userId  number
        # .point   number

コール ツリー:

runAttributesSample:  #ref: ~/unnavigatable/src/app.ts
    # export function  runAttributesSample() {
    #     console.log(`    John: ${getUser("John").point}`);  // John: 110
    getUser:
        # function  getUser(name: string): User {
        #     const  input = getInputParameters(name);
        getInputParameters:
            # function  getInputParameters(name: string): InputRecords {
            #     const  userRecord = getUserRecord(name)
            getUserRecord:
            #         userPointRecord: getPointRecord(userRecord.id),
            getPointRecord:
runGettersSample:
    # export function  runGettersSample() {
    #     console.log(`    John: ${getUser("John").point}`);  // John: 110
    getUser:
        # function  getUser(name: string): User {
        #     return  new User({name: name});
    point:  #// getter
        # class  User implements UserData {
        #     get  point(): number {
        #         return  this.userPointRecord.point;
        userPointRecord:  #// getter
            # get  userPointRecord(): PointRecord {
            #     return  getPointRecord(this.userRecord.id);
            userRecord:  #// getter
                # get  userRecord(): UserRecord {
                #     return  getUserRecord(this.name);
                getUserRecord:
            getPointRecord:
        point:

Discussion