おい、調子はどうだい?
最近、コードレビューをしていて「またか……」と頭を抱えたポイントがあるんだ。
例えば、ユーザーのメールアドレスや年齢、あるいは注文の数量などを扱うとき、君たちはそのまま `string` や `number` でコンポーネントや関数に渡していないかい?
「え? 普通じゃん、何がダメなの?」と思ったそこの君。危ない橋を渡っている自覚を持ったほうがいい。
コードベースが成長するにつれて、「あれ、この関数の第2引数って、円だっけ? ドルだっけ?」「あれ、この文字列、ちゃんとバリデーション通したっけ?」という混乱が必ず発生する。そして、野良の文字列や数値がアプリのあちこちを駆け巡り、バグの温床になる。
今回は、そんなフロントエンドの混沌に秩序をもたらすための特効薬、「値オブジェクト(Value Object)」について話をしよう。JavaScriptのプリミティブと型判定の裏側を知り尽くしたシニアの視点から、実務で即効性のある設計手法を伝授するよ。
—
なぜプリミティブな値だけでは限界が来るのか?
JavaScriptのデータ型には、お馴染みのプリミティブ型(`string`, `number`, `boolean`, `symbol`, `bigint`, `null`, `undefined`)と、それ以外の「オブジェクト型」がある。
typeof演算子を使えば、値の型をざっくり調べることはできるよね。
console.log(typeof “user@example.com”); // “string”
console.log(typeof 1500); // “number”
だが、考えてみてほしい。システム的な「型(typeofの結果)」と、ドメイン上の「概念」は全く別物だ。
「メールアドレス」も「ただの文字列(string)」、「価格」も「ただの数値(number)」として扱っていませんか?
ここに、プリミティブ執着(Primitive Obsession)というアンチパターンが潜んでいる。
- どんな文字列でもメールアドレスの変数に入ってしまう
- マイナスの価格が計算途中で混入しても、JSのエンジンは「数値だからOK」と素知らぬ顔で通してしまう
これを解決するのが値オブジェクトだ。
値オブジェクトとは、「プリミティブな値をクラスでラップし、不変性(Immutability)を保証しつつ、ドメインロジックやバリデーションをその中に閉じ込める設計手法」のこと。OOPの世界では常識だけど、TypeScript全盛期の今のフロントエンドでも、バニラなJavaScriptの足回り固めとして非常に強力な武器になる。
—
現場で使える!堅牢な「値オブジェクト」の実装パターン
百聞は一見に如かず。実際に、ECサイトなどでよくある「価格(Price)」を表現する値オブジェクトを、JavaScript(ES2022以降のクラス構文)で書いてみよう。
そのままエディタにコピーして、Node.jsやブラウザのコンソールで動かせるようにしてある。
/
- 価格を表す値オブジェクト
- 不変性(Immutability)を保ち、不正な値の存在を許さない
/
class Price {
#value; // プライベートフィールド(ES2022〜)で外からの直接書き換えを完全防御
constructor(value) {
// 1. プリミティブな型チェック(typeofの正しい使い方)
if (typeof value !== ‘number’) {
throw new TypeError(‘価格は数値である必要があります。’);
}
// 2. ドメイン固有のバリデーション(負数は絶対に許さない)
if (!Number.isFinite(value) || value < 0) {
throw new RangeError('価格には0以上の有限数を指定してください。');
}
// 3. 浮動小数点の丸め処理などのビジネスロジックをここに閉じ込める
this.#value = Math.round(value);
// 4. インスタンスの変更を不可能にする(Object.freezeの合わせ技)
Object.freeze(this);
}
// 値を取り出すためのゲッター(setterは絶対に用意しない!)
get value() {
return this.#value;
}
// ドメインロジック(計算)も値オブジェクトの責任にする
add(otherPrice) {
if (!(otherPrice instanceof Price)) {
throw new TypeError('Price同士のみ加算可能です。');
}
// 新しいインスタンスを返却する(イミュータブルの鉄則)
return new Price(this.#value + otherPrice.value);
}
// プリミティブな値との比較や、表示用フォーマットもメソッド化できる
isGreaterThan(otherPrice) {
return this.#value > otherPrice.value;
}
// デバッグやログ出力で分かりやすくするための文字列表現
toString() {
return `¥${this.#value.toLocaleString()}`;
}
}
// — 実戦での使い方 —
try {
const priceA = new Price(1000);
const priceB = new Price(500);
// 加算結果は新しい Price オブジェクトになる
const totalPrice = priceA.add(priceB);
console.log(totalPrice.toString()); // “¥1,500”
// 不正な値を入れようとすると、インスタンス化の時点で即座に弾かれる
// const invalidPrice = new Price(-500); // => RangeError: 価格には0以上の有限数を指定してください。
} catch (e) {
console.error(e.message);
}
—
ブラウザやJSエンジンの裏側で何が起きているか?
ここで、少しアーキテクチャの深い話をしよう。
「クラスでラップするってことは、毎回インスタンスを作るからメモリやパフォーマンスに悪影響なんじゃないの?」というパフォーマンスオタクな疑問を持つシニアもいるはずだ。
結論から言うと、現代のJavaScriptエンジン(V8など)は、この手の小規模なオブジェクト生成・破棄の最適化において神がかった仕事をしてくれる。
1. インラインキャッシュと隠しクラス(Hidden Classes):
JSエンジンは、同じ構造を持つオブジェクト(この場合は `Price` クラスのインスタンス)を生成する際、内部的に同じ「隠しクラス」を割り当てる。これにより、プロパティへのアクセス速度は最適化され、プレーンなオブジェクトと遜色ない速度で動作する。
2. ガベージコレクション(GC):
値オブジェクトは基本的に「イミュータブル(不変)」だ。一度作ったら中身が変わらないため、エンジンはメモリ上の配置や参照の最適化を行いやすい。一時的に生成されたオブジェクトの回収は、近年の世代別GCによって極めて高速に行われる。
パフォーマンスの懸念よりも、「不正なデータがアプリケーションの深部に侵入するコスト」や「バグの調査にかかる開発者の時間」のほうが圧倒的に高くつく。ここはケチるべき場所じゃない。
—
実務におけるベストプラクティスと注意点
最後に、現場でこの値オブジェクトを導入する際の心構えをいくつか伝えておこう。
1. 値の等価性(Equality)をどう扱うか
JavaScriptの `===` は、オブジェクトの場合「参照が同じか」を比較する。
const p1 = new Price(1000);
const p2 = new Price(1000);
console.log(p1 === p2); // false (中身は同じなのに!)
中身の値が同じなら等価とみなしたい場合は、`equals` メソッドを実装しておくと実務で非常に助かる。
equals(other) {
if (!(other instanceof Price)) return false;
return this.#value === other.value;
}
2. TypeScriptとの共存
「TypeScript使ってるから、型定義だけで十分じゃね?」と思った君。TypeScriptの型はコンパイル時(ビルド時)に消え去る幻だ。APIのレスポンスやローカルストレージから取得した動的なデータが本当にその型を満たしているかは、実行時(Runtime)には保証してくれない。
だからこそ、TypeScriptを使っていたとしても、実行時バリデーションを兼ねた値オブジェクトを組み合わせるのが、堅牢なフロントエンドを作るための最強のパターンになる。
—
まとめ
値オブジェクトの導入は、最初は少しボイラープレート(お決まりのコード)が増えたように感じるかもしれない。
だが、それは「面倒な作業」ではなく、「ドメインのルールをコードという城壁の門番に教え込む作業」だ。
プリミティブの沼から抜け出し、型とオブジェクトの力を正しく使って、変更に強く、バグの生まれないクリーンなコードベースを築き上げていってほしい。
それじゃ、次のコードレビューを楽しみにしているよ!

コメント