Skip to main content

第34章:Quantity VO:数量と単位📏

この章はね、「2」って数字だけじゃ足りないよね?って話だよ〜😊 たとえば同じ「2」でも…

  • ☕ 2杯(ドリンク)
  • 🍪 2個(クッキー)
  • 🧊 2g(砂糖)

…ぜんぶ意味が違うのに、ただの number で持つと混ざって事故るの🥲💥 だから Quantity(数量)を Value Object にして、単位ごと閉じ込めるよ〜💎📦


今日のゴール🎯💖

できるようになること👇

  • ✅ Quantity VO を作って「数量+単位」を一体で扱える
  • 単位が違う数量は足せない(コンパイルで止める🛑)
  • ✅ 数量のルール(最小1、最大、整数限定など)を 生成時に強制できる
  • ✅ テストで「壊れないQuantity」を作れる🧪✨

まず“最新状況”ちょいメモ🗞️👀(安心のため)

  • TypeScript の安定版は「5.9」になってるよ(公式ダウンロードページに “currently 5.9” って明記)。(TypeScript)
  • TypeScript はネイティブ実装(TypeScript 7)へ移行中で、2025年末時点で進捗が公式から出てるよ(高速化が本格的に進んでる流れ)。(Microsoft for Developers)
  • テストは Vitest 4 が 2025年10月に出てて、移行ガイドも整ってる。(Vitest)

この章の内容は、いま安定して使える機能だけで組むね😊


Quantity VO の設計方針(超だいじ)🧠✨

1) Quantity は「値+単位」のセット🧩

  • value: 数量(例:2)
  • unit: 単位(例:cup / piece)

この2つが分離してると、UIやDTOで unit を落とした瞬間に事故る😇

2) “数えられる単位”は整数が基本🔢

  • cup(杯)/ piece(個)みたいな カウント系は整数が自然
  • g / ml みたいな 計量系は小数が出る(でも float は地雷🧨)

今回はまずカウント系中心で作って、拡張の道も用意するよ〜🚪✨

3) 生成時にルールを全部チェック✅

VOの鉄則どおり、作れた時点で正しい状態にする💎 (あとから setter で直す、とかはやらない🙅‍♀️)


実装してみよ〜💻🌸(Quantity VO)

ファイル例:src/domain/valueObjects/Quantity.ts

// Quantity.ts

// まず「単位」を型にするよ〜📏✨
export const COUNT_UNITS = ["cup", "piece"] as const;
export type CountUnit = (typeof COUNT_UNITS)[number];

// 将来、g / ml を増やすならここを拡張していく感じ👶➡️🧑‍💻
export type Unit = CountUnit;

function isFiniteNumber(v: number): boolean {
return Number.isFinite(v);
}

export class Quantity<U extends Unit = Unit> {
private constructor(
public readonly value: number,
public readonly unit: U,
) {
Object.freeze(this); // うっかり変更防止🧊
}

// ✅ 生成は必ずここから(ルールを強制)
static of<U extends Unit>(value: number, unit: U): Quantity<U> {
if (!isFiniteNumber(value)) {
throw new Error("Quantity: value must be a finite number");
}

// カウント系は整数が基本🔢
if (!Number.isInteger(value)) {
throw new Error("Quantity: count-based value must be an integer");
}

// 例:最小1、最大99(例題向けに現実っぽく)
if (value < 1) {
throw new Error("Quantity: value must be >= 1");
}
if (value > 99) {
throw new Error("Quantity: value must be <= 99");
}

return new Quantity(value, unit);
}

// ✅ 等価性:値+単位が同じなら同じ💎
equals(other: Quantity<Unit>): boolean {
return this.value === other.value && this.unit === other.unit;
}

// ✅ 同じ単位のときだけ足せる(型で守る✨)
add(other: Quantity<U>): Quantity<U> {
return Quantity.of(this.value + other.value, this.unit);
}

// ✅ 同じ単位のときだけ引ける(結果が0以下は禁止にする例)
subtract(other: Quantity<U>): Quantity<U> {
const next = this.value - other.value;
return Quantity.of(next, this.unit);
}

// 画面表示用(単位の日本語表示はドメイン外でもOKだけど、簡易でここに置く例)
// 本気なら「表示」はUI層に寄せてもOKだよ😊
toLabel(): string {
switch (this.unit) {
case "cup":
return `${this.value}`;
case "piece":
return `${this.value}`;
default:
// Unit を増やした時にここが型エラーで気づけるのが嬉しい✨
return `${this.value} ${this.unit}`;
}
}
}

“単位が違うのに足す”を止めたい!🛑💥(型の勝利🏆)

import { Quantity } from "./Quantity";

const cups = Quantity.of(2, "cup"); // Quantity<"cup">
const pieces = Quantity.of(2, "piece"); // Quantity<"piece">

const ok = cups.add(Quantity.of(1, "cup")); // ✅ OK

// const ng = cups.add(pieces);
// ^^^^^^ ❌ ここがコンパイルで止まる(単位が違うから)

これが **“プリミティブ地獄からの脱出”**の気持ちよさだよ〜😭✨


例題(カフェ)に入れてみる☕🧾💕

たとえば注文明細(OrderLine)に quantity を入れるとき👇

import { Quantity } from "../valueObjects/Quantity";

export class OrderLine {
constructor(
public readonly menuItemId: string,
public readonly quantity: Quantity<"cup">, // ☕は杯だけ許可!って縛れる
) {
Object.freeze(this);
}
}
  • 「クッキーは piece、ドリンクは cup」みたいに 商品カテゴリで縛るのもアリだよ🍪☕
  • こういう縛りは、あとで仕様が増えても強い💪✨

テストしよ〜🧪✨(Vitest 4)

ファイル例:src/domain/valueObjects/Quantity.test.ts

import { describe, it, expect } from "vitest";
import { Quantity } from "./Quantity";

describe("Quantity", () => {
it("creates a valid count quantity", () => {
const q = Quantity.of(2, "cup");
expect(q.value).toBe(2);
expect(q.unit).toBe("cup");
});

it("rejects non-integers for count units", () => {
expect(() => Quantity.of(1.5, "cup")).toThrow();
});

it("rejects zero or negative values", () => {
expect(() => Quantity.of(0, "cup")).toThrow();
expect(() => Quantity.of(-1, "cup")).toThrow();
});

it("adds only same unit", () => {
const a = Quantity.of(2, "cup");
const b = Quantity.of(3, "cup");
expect(a.add(b).equals(Quantity.of(5, "cup"))).toBe(true);
});
});

Vitest 4 は 2025年後半からメジャーとして整備が進んでるよ。(Vitest)


“型で落とす”テストもやりたい(tscで守る)🧠🛡️

Vitestは実行時テストだけだから、コンパイルで落ちることは別でチェックすると安心💖

ファイル例:src/type-tests/quantity.type-test.ts

import { Quantity } from "../domain/valueObjects/Quantity";

const cups = Quantity.of(2, "cup");
const pieces = Quantity.of(2, "piece");

// @ts-expect-error 単位が違うのに足し算は禁止
cups.add(pieces);

そして CI か手元でこれ👇

npx tsc -p tsconfig.json --noEmit

“単位が混ざる事故”を コンパイルで防ぐ仕組みが完成〜🎉✨


よくある落とし穴(ここで回避😇⚠️)

🥲 1) quantity を number に戻しちゃう

DTOやDBに出すとき、うっかり value だけ出して unit を落とす…あるある💥 → DTOは { value, unit } の形で持つクセをつけよ🧾✨

🥲 2) 単位変換を Quantity の中に入れすぎる

「cup→mlもできるよ!」とかやり始めると、VOが太る🍔💦 → 変換は 別のサービス(Domain Service/Policy)に逃がすのがキレイ✨

🥲 3) 計量(g / ml)を number の小数で雑に扱う

JSの number は浮動小数で地雷がある🧨 → 計量系は「最小単位(例:mg)で整数保持」や、decimal系を検討するのが安全だよ💡


表示(単位の整形)どうする?🖥️✨

標準の単位(gram, liter みたいな国際的な単位)なら、Intl の仕様にも “単位識別子” があるよ。(TC39) ただし「杯」「個」みたいなドメイン単位は標準じゃないことも多いから、自前マッピングが無難💕


未来メモ:JS/TSで「単位付き数値」を標準化したい動き🌍📦

  • 「値+単位」を扱う提案として、TC39に Amount の提案があるよ(unit などを持てる構想)。(GitHub)
  • TypeScript側でも “Units of Measure” 的なアイデア提案はあるけど、現状は言語機能としては入ってない(提案レベル)。(GitHub)

だから今は、DDDのVOで堅牢に作るのがいちばん現実的だよ〜💎✨


ミニ演習(手を動かすと最強💪🧠)

演習1:上限/下限を“単位ごと”に変える🎚️

  • cup は 1〜99
  • piece は 1〜20 みたいに、unitで条件を変えてみよ😊

演習2:multiply(×)を追加する✖️

  • Quantity.of(2,"cup").multiply(3) → 6杯
  • 0や負数は拒否🙅‍♀️

演習3:OrderLine のルールに入れる🧾🔒

  • 「同じメニューは1行にまとめる(数量加算)」
  • 「支払い後は数量変更禁止」 このへん、前の章の不変条件とつなげられるよ〜✨

AIに頼むと強いプロンプト例🤖💬💖

✅ ルール洗い出し

「カフェ注文の数量(杯/個)に関して、現実的な不正ケースを20個出して。境界値もお願い」

✅ テスト観点

「Quantity VO のテストケースを AAA 形式で列挙して。正常系/異常系/境界値を混ぜて」

✅ 設計レビュー

「この Quantity 実装の弱点を DDD(VOの鉄則:不変/等価性/自己完結)観点でレビューして」


章のまとめ🎀✨

  • Quantity は「number」じゃなくて **意味のある値(数量+単位)**として扱う📏
  • 生成時にバリデーションして “正しいものだけ存在する世界” を作る🔒
  • 単位違いの計算を型で止めるのがめちゃ強い🛡️
  • 表示や変換は入れすぎ注意!VOはスリムが正義💎

次の第35章は「Email VO」だよね✉️✨ Quantityで「入力のゆらぎを吸収する感覚」ができたから、Emailはさらに気持ちよくなるよ〜😊🎉