TypeScript で仕様が一目瞭然な定数ファイルを書く
オブジェクトの中に、定数をフラットに列挙しただけの定数ファイルを書いていませんか?
私は、フロントエンドの開発において、
- JavaScript の簡潔なオブジェクト記法
- TypeScript による型チェック / IDE 等による入力補助
を活用した保守性の高い定数ファイルの書き方を日夜研究しているので、4つのポイントに着目して解説しようと思います。
▼ Enum 的な記述についてまとめた記事です。本記事を読んだあとで、こちらもチェックしてみましょう。
genre.name
)より 「疑似的な名前空間」形式(genre_name
)が無難
(2024/10/15 訂正)「オブジェクト-キー」形式(この記事においては、以下のようなオブジェクトに定数たちをまとめることを推奨していました。この形式を 「オブジェクト-キー形式」 と呼ぶことにします。
object.key
の形式にすることによって、Exported file name of "user posts" page とか、 "User profile" field's label のような、所有関係を含んだ名前を考えるのがラクになる、というメリットを享受することを狙っていました。
🤔 「オブジェクト-キー」形式
export const exportedFileNames = {
userPosts: (userName: string) => `投稿一覧_${userName}さま.json`,
everything: `この世の全て.json`,
} as const
// 使用側
const fileName = exportedFileNames.userPosts(name)
しかし、この「オブジェクト-キー形式」だと、minify 処理による恩恵を受けられず、ブラウザに不要なコードが送信されてパフォーマンスが低下する 可能性があります。オブジェクトのツリーが大きくなるほど、不注意によって容易にこのその問題が発生しやすくなると想像できます。
特に、オブジェクトのメンバーとして、アロー関数ではない function() {}
形式の関数が含まれるときに、書き方・ビルドツール・その設定に依存して起こり得るようです。
上記の 「Enum 的な定数」 であれば、最適化の恩恵が相対的に小さいと考えます。(結局、ルックアップテーブル的に使用するので。)
しかし、今回の記事で説明する 「Config 的な定数」 だと、オブジェクトのツリーが大きくなりやすいので minify 処理から漏れる可能性が大きく、ルックアップテーブル的な使い方でもないので minify に成功したときの恩恵が大きいので、「オブジェクト-キー」形式にこだわる必要が薄いと考えました。
詳しくは、mizchi さんの、以下の記事を参照ください。
そこで、代替案として、以下のような「擬似的な名前空間」形式を使用することを推奨します。
✅️ 「擬似的な名前空間」形式
export const exportedFileNames_userPosts = (userName: string) =>
`投稿一覧_${userName}さま.json`;
export const exportedFileNames_everything = `この世の全て.json`;
// 使用側
const fileName = exportedFileNames_userPosts(name)
この形式を取ることによって、「巨大なオブジェクトのツリー」を作らずに小さな定数を並べることになります。上記のような問題を踏んでしまっても、個別の const に分かれているので、Tree-shaking によって影響範囲が小さくなることが期待できます。
Tree-shaking 困難な状態に陥るのを避けながら、「所有関係のわかりやすさ」を何とか保つために、_
を名前空間のセパレータのように使っています。(JS オブジェクトのドット .
や、C++ の名前空間のダブルコロン ::
のような感覚です。)
(クセが強いんじゃないか、と思って躊躇していましたが、mizchi さんの記事を見て吹っ切れました。僕はドンドン使うつもりです。)
各章に、この「擬似的な名前空間」形式の例を追記しているので、注意して見てもらえると嬉しいです。
この記事で求める「保守性」
網羅的に視認しやすいこと
会社/文化によって異なるかも知れませんが、
「全てのフィールド(ラベル・説明文・バリデーションエラー文言)」のような情報が仕様ドキュメントの中でまとめて管理されている状況では、コードの側でも文書の構造に合わせて情報をまとめて配置すれば、仕様と実コードを見比べるのがラクになるのかな...?と考えています。
また、単純に仕様上重要な情報がボリューミーなコードの中に埋もれやすいので、定数ファイル化して目立ったほうが嬉しい、という考えがあります。
よくカプセル化されていること
仕様が分かりやすいように定数ファイルに抽出したとしても、「使用側コードと定数ファイルを往復しなければ仕様が読み取れない」ようでは、旨みが半減してしまいます。
そのため、定数ファイルの中に仕様が適切に隠蔽されていると嬉しいです。
透明性を確保する
かといって、隠蔽の仕方が下手だと、「定数ファイルにジャンプして確認しないと正しい使い方がわからない」となります。そのため、以下のような配慮があると嬉しいと思います。
- 雄弁な命名 (↔ 短すぎて説明不足)
- IDE等でホバーするだけで詳細がわかる工夫
- JSDoc → 詳細な説明が必要なら
-
as const
→ リテラル型として内容を表示できる
- 「定数ファイル/使用側」の境界線をうまく引く
それでは、具体例を見てみましょう。
1. 構造化して強い(高凝集な)定数宣言に
🔴 DON’T
const numbers = {
pagination: {
limit: 10,
},
userName: {
max: 12
},
} as const;
export const validationMessages = {
max_user_name: "ユーザー名は12文字以内で入力して下さい"
} as const;
- 異なる種類なのに、「数値だから」という理由だけで混在している
- 例: 「文字数」・「ページ毎件数」
- 互いに強く関連する定数がバラバラに存在する
- 例: 「文字数の数値」・「それに対応するエラーメッセージ」
✅ DO
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
- メッセージと、それに対応する実際の数値が同じ場所に配置される
- ただし、これらの値をつかった実際の判定処理・スキーマの組み立ては含まないように
- 必要最低限の記述にとどめることで一覧性を保つ
✅️ 「擬似的な名前空間」形式(2024/10/15 追記)
fields.userName
fields.userProfile
と項目数が増えていくと、オブジェクトのツリーが大きくなりがちです。
なので、その単位のオブジェクトを一つのオブジェクト型定数して、小さなツリーに分解すると、最適化不能になった際でも損失が小さく抑えられると思います。
もっと徹底的に分解して、fields_userName_label
としてしまっても良いですが、そこまで分けると見通しがちょっと悪いような気がするので、このレベルで妥協しました。
export const fields_userName = {
label: "氏名",
hintText: "例:山田 太郎",
maxLength: {
value: 12,
message: "ユーザー名は12文字以内で入力して下さい"
},
} as const
//
const fieldSchemas_userName = (() => {
const { maxLength } = fields_userName
return (
z.string()
.max(maxLength.value, { message: maxLength.message })
);
})() as const
2. DRY 破り — 例) KB, MB の単位の処理は、文字列と数値を別に
🔴 DON’T
export const fields = {
userProfile: {
label: "プロフィール画像",
maxFileSize: 5 << 20, // 5MB
}
} as const
const { maxFileSize } = fields.userProfile
<ImageInput
maxSize={maxFileSize}
errorMessage={{
maxSize: `${maxFileSize / (1 << 20)}MBを超えるファイルはアップロードできません。`
}}
/>
- 使用する側で、「1_000_000で割るんだっけ? (1 << 20)で割るんだっけ?」と迷う
- 「内部的に KiB (1024進法) と KB (1000進法) のどちらを使うか」 という知識が使用側に漏れ出ている。
- 単純に情報量が減ったところから復元しているので気持ち悪い
✅ DO
const mebi = 1 << 20
export const fields = {
userProfile: {
label: "プロフィール画像",
maxFileSize: {
value_bytes: 5 * mebi,
message: "5MBを超えるファイルはアップロードできません。",
},
},
} as const
- 表示用のテキストと、バリデーションに用いる実際の値は別々にする
- 「内部的に KiB と KB のどちらを使うか」 という知識を定数ファイル内に閉じ込める
- DRY, SSoT(信頼すべき唯一の情報源) にこだわりすぎない
- 整合性を保つべき情報源が複数になってしまうが、同じファイル内に限定するので、さほど面倒くさくないはず
✅️ 「擬似的な名前空間」形式(2024/10/15 追記)
1 とほぼ同じなので、説明は割愛します。
const mebi = 1 << 20
export const fields_userProfile = {
label: "プロフィール画像",
maxFileSize: {
value_bytes: 5 * mebi,
message: "5MBを超えるファイルはアップロードできません。",
},
} as const
const { maxFileSize } = fields_userProfile
<ImageInput
maxSize={maxFileSize.value_bytes}
errorMessage={{
maxSize: maxFileSize.message
}}
/>
3. 一時変数を使う — デフォルト値を外に見せない
例: ページネーションのページ毎の件数
🔴 DON’T — 使用箇所は users と ... ?
export const pagination = {
default: {
/** ページあたりの表示件数 */
itemsPerPage: 20,
/** 左右に表示する件数 */
numberOfSiblings: 2,
},
"/users": {
itemsPerPage: 30,
},
} as const
const { itemsPerPage } = pagination["/users"]
const { numberOfSiblings } = pagination.default
- 定数ファイル側では全ケース網羅されていない
- どのケース固有の値を使うのか分からない
- 使用側から見ると保守性が低い
- 「このケースではどのプロパティにデフォルト値を使うの?それとも、固有の値を使う?」
- 知識が使用側のコードに漏れている
- DRY っぽくはあるけど...
✅ DO
const defaultConfig = {
itemsPerPage: 20,
numberOfSiblings: 2,
}
export const pagination = {
"/users/[id]/posts": {
...defaultConfig,
itemsPerPage: 30,
},
"/users": {
...defaultConfig,
},
} as const
const { itemsPerPage, numberOfSiblings } = pagination["/users"]
- 全てのケースを網羅する
- 定数ファイルを見るだけで「デフォルト値」と「ケースごとに上書きされる設定」がともに一目瞭然
- 使用する側は、「どのケースか」で指定する
- 「このケースはデフォルト値を使う」という情報が定数ファイル内に閉じているて、使用側に漏れていない。
- (ここでは使っていないが、)即時関数もアリ?
✅️ 「擬似的な名前空間」形式(2024/10/15 追記)
少し苦しくなってきます。上記のコードは、「オブジェクトのキーとして、文字列リテラルが使える」ことを悪用してパス名("/users/[id]/posts"
)をそのまんまキーにしていました。
オブジェクトのキーを使うのをやめると、それが不可能になります。
ここでは、苦し紛れですが、以下の法則に従って、変数名として正当な形式になおしています。
- 「疑似的な名前空間」の区切りとして
__
を使う - パスのスラッシュを
_
に置き換る - 動的なパスでは、
$
を先頭に付ける
const defaultConfig = {
itemsPerPage: 20,
numberOfSiblings: 2,
}
// pagination["/users/[id]/posts"] だったもの
export const pagination__users_$id_posts = {
...defaultConfig,
itemsPerPage: 30,
} as const
// pagination["/users"] だったもの
export const pagination__users = {
...defaultConfig,
} as const
const { itemsPerPage, numberOfSiblings } = pagination__users
4. 小さな関数を活用 — 知識を他ファイルに漏らさない
🔴 DON’T — 本当に定数だけ
export const exportedFileNames = {
userPosts_prefix: "投稿一覧_",
userPosts_postfix: "さま.json",
} as const
const filename = (
exportedFileNames.userPosts_prefix +
userName +
exportedFileNames.userPosts_postfix
)
- 「定数以外は書いてはいけない」という思い込み
- 使用するコード側に知識が漏出している
🔴 DON’T — 論理的凝集に陥った「大きな関数」
関数の中に知識を閉じ込めたものの...
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 — 小さな関数に閉じ込める
export const exportedFileNames = {
userPosts: (userName: string) => `投稿一覧_${userName}さま.json`,
everything: `この世の全て.json`,
} as const
const { name } = useUserInfo() // 取得
const fileName = exportedFileNames.userPosts(name) // 利用
- 定数側の記述の中に組み立てロジックが閉じ込められている
- データの取得・そのデータ利用という関心がよく分離されている
- 一つ一つの関数が小さい
- 各ケースで必要なデータだけを引数に渡せば良い
- 分岐を増やす(面倒) << 項目を追加する(かんたん)
- これが開放/閉鎖原則(知らんけど)
✅️ 「擬似的な名前空間」形式(2024/10/15 追記)
以上のコードでは、先述した通り、アロー関数じゃないほうの function () {}
が紛れたときに、ビルドツールに最適化を諦められてしまうリスクがあります。
気休め程度かもしれませんが、Tree-shaking しやすい個別の const に分けることで、そのリスクを抑えることが出来ます。
export const exportedFileNames_userPosts = (userName: string) =>
`投稿一覧_${userName}さま.json`;
export const exportedFileNames_everything = `この世の全て.json`;
const { name } = useUserInfo() // 取得
const fileName = exportedFileNames_userPosts(name) // 利用
おわりに
みなさんも仕様を静的に記述して、快適にフロントエンド開発を行いましょう!
▼ この記事の内容を含むスクラップ
Discussion
zodの都合に寄せれば、おのずとそう書きたくなる……よね!?