Dev Study
← 解説「データモデルの型設計 — interfaceは画面の設計図」に戻る

サンプルコードで身につける: データモデルの型設計 — interfaceは画面の設計図

解説で学んだ概念を、5つの具体例で確認します。素朴な例から実務寄りの例へと進みます。

1APIレスポンスに interface で型を付ける

APIが返すJSONの形をそのまま interface に写し、HttpClient の型引数に渡します。これだけでフィールド名の打ち間違いや型違いの計算を、実行する前にコンパイラが捕まえてくれます。

import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';

// APIが返すJSONの形を interface に写す(= データの設計図)
export interface Product {
  id: number;
  name: string;
  price: number;
  stock: number;
}

@Injectable({ providedIn: 'root' })
export class ProductService {
  private http = inject(HttpClient);

  fetchAll() {
    // 型引数を渡すと、受け取った後の扱いがすべてチェックされる
    return this.http.get<Product[]>('/api/products');
  }
}

// p.nama   → コンパイルエラー: Property 'nama' does not exist
// p.price * 1.1 → OK(number だと分かっているから計算できる)

2Before/After — any で逃げると壊れるのが実行時になる

any は型チェックを丸ごと無効にするスイッチです。エラーは消えますが、打ち間違いが画面の undefined 表示として本番で発覚します。壊れる時点が「今」から「実行時」へ先送りされるだけです。

// ── Before: any で逃げた場合 ──
// this.http.get<any>('/api/products').subscribe((list) => {
//   this.total = list[0].prise * list.length;
//   //                  ^^^^^ price の打ち間違いだが、エラーは出ない
// });
// → コンパイルは通る。実行すると undefined * 3 = NaN。
//   画面に「合計: NaN円」と出て、本番で初めて気付く

// ── After: 型を付けた場合 ──
// this.http.get<Product[]>('/api/products').subscribe((list) => {
//   this.total = list[0].prise * list.length;
//   //                  ^^^^^ コンパイルエラー:
//   //   Property 'prise' does not exist on type 'Product'.
//   //   Did you mean 'price'?
// });
// → 書いたその場でエディタが赤線を引く。実行前に直せる

3画面用の型を分けるタイミング

APIの型に画面用の計算値を混ぜ始めたら分け時です。APIの型はサーバーの形をそのまま、画面用の型は表示に必要な形を持ち、変換関数で橋渡しすると変更の影響が閉じます。

// ── Before: APIの型に画面の都合を混ぜてしまった ──
// export interface Product {
//   id: number;
//   name: string;
//   price: number;
//   priceWithTax?: number;  // 画面で計算した値。APIは返さないので optional…
//   isLowStock?: boolean;   // これもAPIには存在しない
// }
// → どのフィールドが本物のAPI由来か分からなくなっていく

// ── After: APIの型と画面用の型を分け、変換関数でつなぐ ──
export interface ProductResponse {
  id: number;
  name: string;
  price: number;   // APIが返すままの形(サーバーの都合で変わる)
  stock: number;
}

export interface ProductView {
  id: number;
  name: string;
  priceLabel: string;   // 表示用: '1,320円(税込)'
  isLowStock: boolean;  // 表示用: 在庫わずか表示の判定
}

export function toProductView(res: ProductResponse): ProductView {
  return {
    id: res.id,
    name: res.name,
    priceLabel: Math.round(res.price * 1.1).toLocaleString() + '円(税込)',
    isLowStock: res.stock < 5,
  };
}

4optional と union で「無いかもしれない」を設計図に書く

APIが返さないことのあるフィールドは ? を付け、決まった文字列しか来ない値は union 型にします。あり得る形を型に書いておくほど、扱い漏れをコンパイラが見つけてくれます。

// 「無いかもしれない」「これしか来ない」も設計図に書く
export interface Order {
  id: number;
  // 決まった値しか来ないなら union 型にする
  // → status === 'shiped' のような打ち間違いが即エラーになる
  status: 'pending' | 'paid' | 'shipped' | 'cancelled';

  // 発送前は無いフィールドには ? を付ける
  shippedAt?: string;
}

export function shippingLabel(order: Order): string {
  // shippedAt は string | undefined なので、
  // チェックせずに使おうとするとコンパイラが指摘してくれる
  if (order.shippedAt) {
    return order.shippedAt + ' に発送済み';
  }
  return '未発送';
}

5型ファイルの置き場所 — model.ts に集める

型はサービスのファイルに埋め込まず、機能フォルダの model.ts にまとめます。コンポーネントとサービスの両方が同じ設計図を参照でき、「この機能のデータの形」を1ファイルで見渡せます。

// ── Before: 型が product.service.ts の中に埋まっている ──
// コンポーネントが型だけ欲しいのにサービスを import する羽目になり、
// データの形を確認するにもサービスのコードをかき分けることになる

// ── After: features/products/product.model.ts に集める ──
//
// features/products/
// ├── product.model.ts    # ★ この機能のデータの形はここを見れば分かる
// ├── product.service.ts  # import { Product } from './product.model';
// └── product-card.ts     # import { Product } from './product.model';

// product.model.ts の中身は型と純粋な変換関数だけにする
export interface Product {
  id: number;
  name: string;
  price: number;
  stock: number;
}

export function isInStock(product: Product): boolean {
  return product.stock > 0;
}