TypeScript Mapped Type の実務活用ガイド|型安全性を高める実装パターン

TypeScript

TypeScript Mapped Type の実務活用ガイド|型安全性を高める実装パターン

はじめに

TypeScriptのMapped Typeは、既存の型から新しい型を自動生成する強力な機能です。教科書的な説明では「型を変換する機能」に留まりますが、実務では大きく異なります。本記事では、実際のプロダクション環境で直面する課題を解決するために、Mapped Typeをどのように活用すべきかを、具体的なコード例とともに解説します。

Mapped Type とは|簡易的な解説

Mapped Typeは、オブジェクト型のキーを反復処理し、新しい型を生成する機能です。基本的な構文は以下の通りです。

// 基本的な Mapped Type の構文
type Readonly<T> = {
  readonly [K in keyof T]: T[K]
}

// 使用例
type User = {
  id: number
  name: string
  email: string
}

type ReadonlyUser = Readonly<User>
// 結果: { readonly id: number; readonly name: string; readonly email: string }

keyof Tで型のキーを取得し、[K in keyof T]でそれらを反復処理します。新しい型のプロパティ値はT[K]で元の型から取得します。これにより、DRY原則に従い、メンテナンス性を大幅に向上させられます。

業務でのユースケース

ユースケース1:API レスポンスの型変換

バックエンドから受け取るAPIレスポンスの型情報から、フロントエンドで必要な型を自動生成するシーン。特にマイクロサービス環境では複数のAPIからデータを取得し、それぞれの応答形式を統一する必要があります。

ユースケース2:フォームの検証ルール管理

フォームのスキーマに基づいて検証ルールの型を自動生成し、検証ロジックとの一貫性を保つシーン。手動で型を管理するとスキーマと検証ルールが乖離しやすくなります。

ユースケース3:状態管理の Action 型定義

Redux や Vuex などの状態管理ライブラリでアクション型を自動生成するシーン。新しいアクションを追加するたびに複数の箇所を更新する手間が削減できます。

ユースケース4:データベースエンティティの型同期

ORMから自動生成されたエンティティ型に基づいて、API レスポンス型や DTO 型を同期させるシーン。スキーマ変更の影響が自動的に反映されます。

実装コード|実務で即座に活用できるパターン

パターン1:API レスポンスの最小化

バックエンドが返すすべてのフィールドが必要ではないことが多いです。APIレスポンスから指定したフィールドのみを抽出する型を生成します。

// バックエンド側のユーザーエンティティ
type UserEntity = {
  id: number
  name: string
  email: string
  passwordHash: string
  createdAt: Date
  updatedAt: Date
  deletedAt: Date | null
  roleId: number
  status: 'active' | 'inactive' | 'suspended'
}

// フロントエンドで必要なフィールドのみを抽出
type Pick<T, K extends keyof T> = {
  [P in K]: T[P]
}

type UserDTO = Pick<UserEntity, 'id' | 'name' | 'email' | 'status'>
// 結果: { id: number; name: string; email: string; status: 'active' | 'inactive' | 'suspended'; }

// さらに実務的:共通で使うフィールドセットを定義
type CommonUserFields = 'id' | 'name' | 'email' | 'status' | 'createdAt'
type PublicUserDTO = Pick<UserEntity, CommonUserFields>

パターン2:全フィールドをオプションに変換

部分更新(PATCH リクエスト)を処理する際に、すべてのフィールドをオプションにする必要があります。

// 部分更新用のDTO型を生成
type Partial<T> = {
  [K in keyof T]?: T[K]
}

type UserUpdateDTO = Partial<Pick<UserEntity, 'name' | 'email' | 'status'>>
// 結果: { name?: string; email?: string; status?: 'active' | 'inactive' | 'suspended'; }

// API ハンドラー内での使用
function updateUser(id: number, update: UserUpdateDTO): Promise<UserDTO> {
  // 型安全にフィールドをチェック
  if (update.name !== undefined) {
    // name を更新
  }
  if (update.email !== undefined) {
    // email を更新
  }
  // ...
  return Promise.resolve({} as UserDTO)
}

パターン3:フォーム検証スキーマの自動生成

実際のプロジェクトでは、フォームの構造とバリデーションルールの定義が分離することが多く、これが不具合の原因になります。Mapped Typeを使って同期させます。

// フォームの型定義
type RegistrationForm = {
  username: string
  email: string
  password: string
  passwordConfirm: string
  agreeToTerms: boolean
}

// バリデーションルール型を自動生成
type ValidationRule<T> = {
  [K in keyof T]: {
    required: boolean
    minLength?: number
    maxLength?: number
    pattern?: RegExp
    custom?: (value: T[K]) => boolean | string
  }
}

type FormValidationRules = ValidationRule<RegistrationForm>

// 実装:フォーム定義と検証ルールを組み合わせた実用的なバリデーター
const validationRules: FormValidationRules = {
  username: {
    required: true,
    minLength: 3,
    maxLength: 20,
    pattern: /^[a-zA-Z0-9_]+$/,
  },
  email: {
    required: true,
    pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
  },
  password: {
    required: true,
    minLength: 8,
    custom: (value) => {
      const hasUpper = /[A-Z]/.test(value)
      const hasLower = /[a-z]/.test(value)
      const hasNumber = /\d/.test(value)
      return hasUpper && hasLower && hasNumber ? true : 'パスワードは大文字・小文字・数字を含む必要があります'
    },
  },
  passwordConfirm: {
    required: true,
  },
  agreeToTerms: {
    required: true,
    custom: (value) => value === true ? true : '利用規約に同意してください',
  },
}

// バリデーション実行関数
function validateForm(form: RegistrationForm): Record<keyof RegistrationForm, string | null> {
  const errors: Partial<Record<keyof RegistrationForm, string>> = {}
  
  ;(Object.keys(validationRules) as Array<keyof RegistrationForm>).forEach((field) => {
    const rule = validationRules[field]
    const value = form[field]
    
    if (rule.required && !value) {
      errors[field] = `${field}は必須です`
      return
    }
    
    if (typeof value === 'string') {
      if (rule.minLength && value.length < rule.minLength) {
        errors[field] = `${rule.minLength}文字以上入力してください`
        return
      }
      if (rule.maxLength && value.length > rule.maxLength) {
        errors[field] = `${rule.maxLength}文字以下にしてください`
        return
      }
      if (rule.pattern && !rule.pattern.test(value)) {
        errors[field] = `形式が正しくありません`
        return
      }
    }
    
    if (rule.custom) {
      const result = rule.custom(value)
      if (result !== true) {
        errors[field] = typeof result === 'string' ? result : `入力が正しくありません`
      }
    }
  })
  
  // すべてのキーを含むレコードを返す
  const allErrors: Record<keyof RegistrationForm, string | null> = {
    username: errors.username ?? null,
    email: errors.email ?? null,
    password: errors.password ?? null,
    passwordConfirm: errors.passwordConfirm ?? null,
    agreeToTerms: errors.agreeToTerms ?? null,
  }
  
  return allErrors
}

パターン4:Redux の Action 型管理

Redux でアクションクリエーターと対応するアクション型を自動生成し、ボイラープレートを削減します。

// アクション定義
type UserActions = {
  FETCH_USER: { id: number }
  UPDATE_USER: { id: number; name: string; email: string }
  DELETE_USER: { id: number }
  LOGOUT: {}
}

// Mapped Type でアクション型を生成
type Action<T extends Record<string, any>> = {
  [K in keyof T]: {
    type: K
    payload: T[K]
  }
}[keyof T]

type UserAction = Action<UserActions>
// 結果: { type: 'FETCH_USER'; payload: { id: number } } | { type: 'UPDATE_USER'; payload: {...} } | ...

// ディスパッチ関数が型安全になる
function dispatch(action: UserAction): void {
  switch (action.type) {
    case 'FETCH_USER':
      // action.payload は { id: number } と推論される
      console.log(action.payload.id)
      break
    case 'UPDATE_USER':
      // action.payload は { id: number; name: string; email: string } と推論される
      console.log(action.payload.name)
      break
    case 'DELETE_USER':
      console.log(action.payload.id)
      break
    case 'LOGOUT':
      // action.payload は {} と推論される
      break
  }
}

// アクションクリエーター関数も型安全に
type ActionCreator<T extends Record<string, any>> = {
  [K in keyof T]: (payload: T[K]) => { type: K; payload: T[K] }
}

const userActionCreators: ActionCreator<UserActions> = {
  FETCH_USER: (payload) => ({ type: 'FETCH_USER', payload }),
  UPDATE_USER: (payload) => ({ type: 'UPDATE_USER', payload }),
  DELETE_USER: (payload) => ({ type: 'DELETE_USER', payload }),
  LOGOUT: (payload) => ({ type: 'LOGOUT', payload }),
}

パターン5:データベースエンティティから複数の DTO を生成

単一のエンティティ型から、異なる用途の複数の DTO を生成する実務的なパターンです。

// ORM で自動生成されるようなエンティティ
type ProductEntity = {
  id: number
  name: string
  description: string
  price: number
  cost: number // 原価(機密)
  inventory: number
  createdAt: Date
  updatedAt: Date
  supplierId: number
  isActive: boolean
  internalNotes: string // 内部メモ
}

// 顧客向けAPI用 DTO
type ProductPublicDTO = Pick<ProductEntity, 'id' | 'name' | 'description' | 'price' | 'inventory' | 'isActive'>

// 管理画面用 DTO(全情報表示)
type ProductAdminDTO = Pick<ProductEntity, 'id' | 'name' | 'description' | 'price' | 'cost' | 'inventory' | 'createdAt' | 'updatedAt' | 'supplierId' | 'isActive' | 'internalNotes'>

// 更新リクエスト用 DTO(一部フィールドのみ更新可能)
type ProductUpdateDTO = Partial<Pick<ProductEntity, 'name' | 'description' | 'price' | 'cost' | 'inventory' | 'isActive' | 'internalNotes'>>

// 作成リクエスト用 DTO(id と timestamp は除外)
type ProductCreateDTO = Omit<ProductEntity, 'id' | 'createdAt' | 'updatedAt'>

// API レスポンス型を定義するヘルパー
type ApiResponse<T> = {
  success: boolean
  data?: T
  error?: string
}

// 実際の API ハンドラー
function getProductForCustomer(id: number): Promise<ApiResponse<ProductPublicDTO>> {
  // 実装
  return Promise.resolve({ success: true, data: {} as ProductPublicDTO })
}

function getProductForAdmin(id: number): Promise<ApiResponse<ProductAdminDTO>> {
  // 実装
  return Promise.resolve({ success: true, data: {} as ProductAdminDTO })
}

よくある応用パターン

応用1:型の条件付き変換

特定の型のフィールドのみを抽出または変換する高度なパターンです。

// 数値型のフィールドのみを抽出
type NumericFields<T> = {
  [K in keyof T as T[K] extends number ? K : never]: T[K]
}

type UserEntity = {
  id: number
  name: string
  age: number
  email: string
  score: number
}

type UserNumericFields = NumericFields<UserEntity>
// 結果: { id: number; age: number; score: number }

// 文字列型をすべてアップケースにする
type Uppercase<T> = {
  [K in keyof T]: T[K] extends string ? Uppercase<T[K]> : T[K]
}

応用2:読み取り専用フィールドと読み書き可能フィールドの分離

一部のフィールドは読み取り専用にしたい場合があります。

// 読み取り専用フィールドを定義
type Readonly<T> = {
  readonly [K in keyof T]: T[K]
}

// 読み取り専用性を削除
type Mutable<T> = {
  -readonly [K in keyof T]: T[K]
}

// ハイブリッド:特定フィールドを読み取り専用に
type ReadonlyFields<T, K extends keyof T> = {
  readonly [P in K]: T[P]
} & {
  [P in Exclude<keyof T, K>]: T[P]
}

type User = {
  id: number
  name: string
  email: string
  createdAt: Date
}

// id と createdAt は読み取り専用、name と email は編集可能
type UserWithReadonlyMeta = ReadonlyFields<User, 'id' | 'createdAt'>

応用3:ゲッター/セッターペアの自動生成

データクラス的なパターンでゲッターとセッターを自動生成します。

type PropertyDescriptors<T> = {
  [K in keyof T]: {
    get(): T[K]
    set(value: T[K]): void
  }
}

type User = {
  id: number
  name: string
  email: string
}

// ゲッター/セッターの型定義
type UserProperties = PropertyDescriptors<User>

// 実装例
class UserModel {
  private _id: number = 0
  private _name: string = ''
  private _email: string = ''
  
  get id(): number {
    return this._id
  }
  
  set id(value: number) {
    this._id = value
  }
  
  get name(): string {
    return this._name
  }
  
  set name(value: string) {
    this._name = value
  }
  
  get email(): string {
    return this._email
  }
  
  set email(value: string) {
    this._email = value
  }
}

注意点と落とし穴

注意1:過度な複雑性

Mapped Type は強力ですが、過度に複雑な型定義はコードの可読性を損なわせます。特にチーム開発では、複雑な型は説明コメントを付けるか、ドキュメント化が必須です。

// ❌ 悪い例:複雑すぎて意図が不明確
type NestedMappedType<T> = {
  [K in keyof T]: T[K] extends object ? {
    [P in keyof T[K]]: T[K][P] extends string ? Uppercase<T[K][P]> : T[K][P]
  } : T[K]
}

// ✅ 良い例:意図を明確にし、段階的に定義
/** オブジェクト型の全ネストされた文字列プロパティを大文字に変換する */
type DeepUppercaseStrings<T> = {
  [K in keyof T]: T[K] extends object
    ? NestedUppercaseStrings<T[K]>
    : T[K] extends string
    ? Uppercase<T[K]>
    : T[K]
}

/** ネストされたオブジェクトの文字列を大文字化するヘルパー */
type NestedUppercaseStrings<T> = {
  [K in keyof T]: T[K] extends string ? Uppercase<T[K]> : T[K]
}

注意2:パフォーマンスへの影響

非常に大規模な型やディープなネスト構造の場合、TypeScript のコンパイルが遅くなることがあります。複雑な型計算はビルド時に影響します。

// パフォーマンスが問題になる可能性
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K]
}

// 大規模なエンティティに対して使用するとコンパイルが遅くなる可能性あり
type LargeEntityReadonly = DeepReadonly<VeryLargeEntity>

// 対策:必要な範囲だけに適用
type PartialDeepReadonly<T, Depth extends number = 1> = Depth extends 0
  ? T
  : {
      readonly [K in keyof T]: T[K] extends object
        ? PartialDeepReadonly<T[K], Depth extends 1 ? 0 : 1>
        : T[K]
    }

注意3:型の互換性問題

Mapped Type で生成した型が意図した互換性を持つかを確認してください。特に never 型が混在する場合は注意が必要です。

// 危険:空のキーが発生する可能性
type OnlyStrings<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K]
}

type Test = { a: string; b: number; c: string }
type Result = OnlyStrings<Test>
// 結果: { a: string; c: string } ✓ 期待通り

// しかし以下のような場合は注意
type AllNumbers = { a: number; b: number }
type OnlyNumberStrings = OnlyStrings<AllNumbers>
// 結果: {} (空のオブジェクト型)
// これは想定と異なる可能性

注意4:バージョン互換性

Mapped Type の高度な機能(Conditional Types や Key Remapping など)は TypeScript のバージョンによって利用可能性が異なります。

// TypeScript 4.4+ : Key Remapping が使用可能
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}

type User = { id: number; name: string }
type UserGetters = Getters<User>
// 結果: { getId: () => number; getName: () => string }

// TypeScript 4.0 以下では上記は使用できない
// プロジェクトの最小 TypeScript バージョンを確認して使用すること

実務での推奨パターン

Mapped Type を実務で効果的に活用するには、以下の方針を心がけましょう:

// ベストプラクティス:用途別に型を整理し、ドキュメント化する

// 1. ベース型を定義
type UserEntity = {
  id: number
  name: string
  email: string
  passwordHash: string
  createdAt: Date
  updatedAt: Date
}

// 2. 共通の型変換を定義
/** 型からセンシティブなフィールドを除外するヘルパー */
type Sanitize<T, Keys extends keyof T> = Omit<T, Keys>

// 3. ドメイン別に DTO を定義
/** 顧客向けAPI用 - センシティブ情報を除外 */
type UserPublicDTO = Sanitize<UserEntity, 'passwordHash' | 'updatedAt'>

/** 認証済みユーザー向けAPI */
type UserPrivateDTO = Omit<UserEntity, 'passwordHash'>

/** 管理画面用 - 全フィールド */
type UserAdminDTO = UserEntity

/** 更新リクエスト - 編集可能なフィールドのみ、オプション */
type UserUpdateRequest = Partial<Pick<UserEntity, 'name' | 'email'>>

// 4. 実装で一貫性を保つ
class UserService {
  // 型安全な API レスポンスメソッド
  async getPublicUser(id: number): Promise<UserPublicDTO> {
    // パスワードハッシュは返さない
    return {} as UserPublicDTO
  }
  
  async getOwnProfile(id: number): Promise<UserPrivateDTO> {
    // パスワードハッシュ以外は返す
    return {} as UserPrivateDTO
  }
  
  async updateUser(id: number, update: UserUpdateRequest): Promise<UserPrivateDTO> {
    // name と email のみ更新可能
    return {} as UserPrivateDTO
  }
}

まとめ

TypeScript の Mapped Type は、単なる「型を変換する機能」ではなく、実務での DRY 原則を実現し、保守性を大幅に向上させるツールです。

重要なポイント:

  • スキーマと実装の一貫性を自動化する – エンティティ型を一度定義すれば、DTO や API レスポンス型は自動生成できます
  • 型安全性を確保しながらボイラープレートを削減 – 手動で複数の型を定義する手間と誤りを排除します
  • 拡張性を高める – エンティティを更新すると関連する全型定義が自動的に追従します
  • チーム開発での保守コストを削減 – 定義の重複がなくなり、レビュー負担も減少します

初めは複雑に見えるかもしれませんが、フォーム検証、API レスポンス管理、状態管理など、実務の多くのシーンで即座に活用できます。段階的に導入し、チーム内で共通の型設計パターンを確立することで、TypeScript の潜在力を最大限に引き出すことができるでしょう。

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