📛

TypeScript で仕様が一目瞭然な定数ファイルを書く

Honey322022/11/23に公開1件のコメント

オブジェクトの中に、定数をフラットに列挙しただけの定数ファイルを書いていませんか?

私は、フロントエンドの開発において、

  • JavaScript の簡潔なオブジェクト記法
  • TypeScript による型チェック / IDE 等による入力補助

を活用した保守性の高い定数ファイルの書き方を日夜研究しているので、4つのポイントに着目して解説しようと思います。

この記事で求める「保守性」

網羅的に視認しやすいこと

会社/文化によって異なるかも知れませんが、

「全てのフィールド(ラベル・説明文・バリデーションエラー文言)」のような情報が仕様ドキュメントの中でまとめて管理されている状況では、コードの側でも文書の構造に合わせて情報をまとめて配置すれば、仕様と実コードを見比べるのがラクになるのかな...?と考えています。

また、単純に仕様上重要な情報がボリューミーなコードの中に埋もれやすいので、定数ファイル化して目立ったほうが嬉しい、という考えがあります。

よくカプセル化されていること

仕様が分かりやすいように定数ファイルに抽出したとしても、「使用側コードと定数ファイルを往復しなければ仕様が読み取れない」ようでは、旨みが半減してしまいます。

そのため、定数ファイルの中に仕様が適切に隠蔽されていると嬉しいです。

透明性を確保する

かといって、隠蔽の仕方が下手だと、「定数ファイルにジャンプして確認しないと正しい使い方がわからない」となります。そのため、以下のような配慮があると嬉しいと思います。

  • 雄弁な命名 (↔ 短すぎて説明不足)
  • IDE等でホバーするだけで詳細がわかる工夫
    • JSDoc → 詳細な説明が必要なら
    • as const → リテラル型として内容を表示できる
  • 「定数ファイル/使用側」の境界線をうまく引く

それでは、具体例を見てみましょう。

1. 構造化して強い(高凝集な)定数宣言に

🔴 DON’T

consts/numbers.ts
const numbers = {
  pagination: {
    limit: 10,
  },
  userName: { 
    max: 12 
  },
} as const;
consts/validation_messages.ts
export const validationMessages = {
  max_user_name: "ユーザー名は12文字以内で入力して下さい"
} as const;
  • 異なる種類なのに、「数値だから」という理由だけで混在している
    • 例: 「文字数」・「ページ毎件数」
  • 互いに強く関連する定数がバラバラに存在する
    • 例: 「文字数の数値」・「それに対応するエラーメッセージ」

DO

consts/fields.ts
export const fields = {
  userName: {
    label: "氏名",
    hintText: "例:山田 太郎",
    maxLength: {
      value: 12,
      message: "ユーザー名は12文字以内で入力して下さい"
    },
  },
} as const

// 使用側のコード (zod 使用)

const fieldSchemas = {
  userName: (() => {
    const { maxLength } = fields.userName
    return (
      z.string()
        .max(maxLength.value, { message: maxLength.message })
    );
  })(),
} as const
  • メッセージと、それに対応する実際の数値が同じ場所に配置される
  • ただし、これらの値をつかった実際の判定処理・スキーマの組み立ては含まないように
    • 必要最低限の記述にとどめることで一覧性を保つ

2. DRY 破り — 例) KB, MB の単位の処理は、文字列と数値を別に

🔴 DON’T

consts/fields.ts
export const fields = {
  userProfile: {
    label: "プロフィール画像",
    maxFileSize: 5 << 20, // 5MB
  }
} as const
ProfileImageInput.ts
const { maxFileSize } = fields.userProfile

<ImageInput
  maxSize={maxFileSize}
  errorMessage={{
    maxSize: `${maxFileSize / (1 << 20)}MBを超えるファイルはアップロードできません。`
  }}
>
  • 使用する側で、「1_000_000で割るんだっけ? (1 << 20)で割るんだっけ?」と迷う
  • 「内部的に KiB (1024進法) と KB (1000進法) のどちらを使うか」 という知識が使用側に漏れ出ている。
  • 単純に情報量が減ったところから復元しているので気持ち悪い

DO

consts/fields.ts
const mebi = 1 << 20

export const fields = {
  userProfile: {
    label: "プロフィール画像",
    maxFileSize: {
      value_bytes: 5 * mebi,
      message: "5MBを超えるファイルはアップロードできません。",
    },
  },
} as const
  • 表示用のテキストと、バリデーションに用いる実際の値は別々にする
  • 「内部的に KiB と KB のどちらを使うか」 という知識を定数ファイル内に閉じ込める
  • DRY, SSoT(信頼すべき唯一の情報源) にこだわりすぎない
    • 整合性を保つべき情報源が複数になってしまうが、同じファイル内に限定するので、さほど面倒くさくないはず

3. 一時変数を使う — デフォルト値を外に見せない

例: ページネーションのページ毎の件数

🔴 DON’T — 使用箇所は users と ... ?

consts/pagination.ts
export const pagination = {
  default: {
    /** ページあたりの表示件数 */
    itemsPerPage: 20,
    /** 左右に表示する件数 */
    numberOfSiblings: 2,
  },
  "/users": {
    itemsPerPage: 30,
  },
} as const
pages/users/items.tsx
const { itemsPerPage } = pagination["/users"]
const { numberOfSiblings } = pagination.default
  • 定数ファイル側では全ケース網羅されていない
    • どのケース固有の値を使うのか分からない
  • 使用側から見ると保守性が低い
    • 「このケースではどのプロパティにデフォルト値を使うの?それとも、固有の値を使う?」
    • 知識が使用側のコードに漏れている
  • DRY っぽくはあるけど...

DO

consts/pagination.ts
const defaultConfig = {
  itemsPerPage: 20,
  numberOfSiblings: 2,
}

export const pagination = {
  "/users/[id]/posts": {
    ...defaultConfig,
    itemsPerPage: 30,
  },
  "/users": {
    ...defaultConfig,
  },
} as const
pages/users/index.tsx
const { itemsPerPage, numberOfSiblings } = pagination["/users"]
  • 全てのケースを網羅する
    • 定数ファイルを見るだけで「デフォルト値」と「ケースごとに上書きされる設定」がともに一目瞭然
  • 使用する側は、「どのケースか」で指定する
    • 「このケースはデフォルト値を使う」という情報が定数ファイル内に閉じているて、使用側に漏れていない。
  • (ここでは使っていないが、)即時関数もアリ?

4. 小さな関数を活用 — 知識を他ファイルに漏らさない

🔴 DON’T — 本当に定数だけ

consts/exportedFileNames.ts
export const exportedFileNames = {
  userPosts_prefix: "投稿一覧_",
  userPosts_postfix: "さま.json",
} as const
pages/user/posts.tsx
const filename = (
  exportedFileNames.userPosts_prefix + 
    userName + 
    exportedFileNames.userPosts_postfix
)
  • 「定数以外は書いてはいけない」という思い込み
  • 使用するコード側に知識が漏出している

🔴 DON’T — 論理的凝集に陥った「大きな関数」

関数の中に知識を閉じ込めたものの...

consts/getExportedFileNames.ts
type Key = "userPosts" | "everything";
type Options = { userName?: string }
export const getExportedFileNames = (key: Key, options: Options) => {
  switch (key) {
    case "userPosts": return `投稿一覧_${options.userName}さま.json`;
    case "everything": return `この世の全て.json`;
  }
}
  • userPosts にしか使わない userName を Options に生やしている
  • それぞれのケースにおける必要十分なオプションが分からない

DO — 小さな関数に閉じ込める

consts/exportedFileNames.ts
export const exportedFileNames = {
  userPosts: (userName: string) => `投稿一覧_${userName}さま.json`,
  everything: `この世の全て.json`,
} as const
pages/user/posts.tsx
const { name } = useUserInfo() // 取得
const fileName = exportedFileNames.userPosts(name) // 利用
  • 定数側の記述の中に組み立てロジックが閉じ込められている
  • データの取得・そのデータ利用という関心がよく分離されている
  • 一つ一つの関数が小さい
    • 各ケースで必要なデータだけを引数に渡せば良い
    • 分岐を増やす(面倒) << 項目を追加する(かんたん)
      • これが開放/閉鎖原則(知らんけど)

おわりに

みなさんも仕様を静的に記述して、快適にフロントエンド開発を行いましょう!

▼ この記事の内容を含むスクラップ

https://zenn.dev/honey32/scraps/d08e97542f8a4a

株式会社ゆめみ

みんな知ってるあのサービスも、ゆめみが一緒に作ってます。スマホアプリ/Webサービスの企画・UX/UI設計、開発運用の内製化支援。Swift,Kotlin,Rust,Go,Flutter,ML,React,AWS等エンジニア・クリエイターの会社です。Twitterで情報配信中

Discussion

zodの都合に寄せれば、おのずとそう書きたくなる……よね!?

ログインするとコメントできます