Location via proxy:   [ UP ]  
[Report a bug]   [Manage cookies]                
📛

TypeScriptで「選択肢」の定義をEnum的な定数にまとめる――satisfiesとSSoTもあるよ

2024/08/12に公開4

ソート順の選択プルダウンがある一覧系ページを実装するとき、選択肢たちの管理方法に頭を悩ませることが多いと思います。

「商品一覧」ページ。ソート順のプルダウンがあり、現在は「価格が高い順」が選ばれている。URL は http://localhost:3000/search-items?sort=price&order=desc
商品一覧ページの概要

ソート順プルダウンが展開され、選択肢が表示されている。選択肢は「価格が高い順」「価格が安い順」「平均評価が高い順」「平均評価が低い順」の4つ。
ソート順プルダウンの選択肢たち

上の画像に示したような場合だと、

  • 《クエリパラメータ》と《選択肢》の間の相互変換
    • ?sort=price&order=desc <--> 「価格が高い順」
  • 《select の状態に使うための文字列》と《選択肢》の間の相互変換
    • <option value={id}>{label}</option>
    • クエリパラメータが sort order の2つあり、それらをそのまま流用できないので

最低限でも、これらの変換ロジックを用意しておく必要がありますね。

この記事では、このような「選択肢と、それにまつわる変換ロジック群」を整理する方法を、高凝集SSoT (Single Source of Truth; 信頼できる唯一の情報源) のキーワードに沿って解説します。


▼ 今回は 「Enum 的な定数」について述べますが、「config 的な定数」についてまとめた記事も既にあります。そちらもチェックしてみましょう。

https://zenn.dev/yumemi_inc/articles/js-front-constants-a1fb3c49eb1199

全体の構成

  • フレームワーク等に依存しない純粋な定義・ロジックをまとめたファイル
    • 1. 「選択肢」の定義をまとめた連想配列形式の定数(SSoT)
    • 2. 1 を使った、逆引きロジック(id から / {sort,order} から)
  • 3. 2 を使った、クエリパラメータと「選択肢」間の相互変換処理

全体の構成はこのようになっています。3 → 2 → 1 のように依存していて、逆方向の依存が無い、いわゆるクリーンアーキテクチャ的な規則を守っています。

今回のコードの全文は、以下のリポジトリにあります。

https://github.com/honey32/next-enum-const

1. SSoT―選択肢の定義(詳細情報)をまとめた連想配列

まずは、あらゆる変換ロジックのための情報を集約した SSoT としての定数を見てみましょう。

app/search-items/__models/misc/sorting.ts
/** 
 * 「ソート条件・ソート順」の選択肢の一つを表す型
 */
export type SortingOption = {
  /** 一意の id。select の value として使う。*/
  id: string;
  /** sort クエリパラメータの値 */
  sort: string;
  /** order クエリパラメータの値 */
  order: string;
  /** 表示名。select の表示等に使う */
  label: string;
};

/**
 * 「商品一覧」ページの「ソート順」の選択肢をまとめた SSoT
 */
export const sortingOptions = {
  priceDesc: {
    id: "priceDesc",
    sort: "price",
    order: "desc",
    label: "価格が高い順",
  },
  priceAsc: {
    id: "priceAsc",
    sort: "price",
    order: "asc",
    label: "価格が安い順",
  },
  ratingDesc: {
    id: "ratingDesc",
    sort: "rating",
    order: "desc",
    label: "平均評価が高い順",
  },
  ratingAsc: {
    id: "ratingAsc",
    sort: "rating",
    order: "asc",
    label: "平均評価が低い順",
  },
} as const satisfies Record<string, SortingOption>;

/**
 * {@link SortingOption} の id が取りうる値のユニオン型。
 */
export type SortingOptionId = keyof typeof sortingOptions;

1-1. Q:なぜ配列じゃなくて連想配列? id を2回も書いて DRY はどうした?

🤔 sortingOptions は複数のオブジェクトを保持しているだけなので、わざわざ id を 2回ずつ書く手間を掛けて 連想配列の形式{ priceDesc: { id: "priceDesc" }, ... } を書くまでも無いような……、

配列の形式 [{ id: "priceDesc" }, ...] で十分じゃないか?

と疑問を持たれるかもしれません。


ここで、この定数の情報を使って「デフォルト値として、必ず『価格が安い順』を使う」ロジックを書いてみましょう。

❌ sortingOptions が配列形式の場合
const defaultOption_1 = sortingOptions[1] // priceAsc を得るためのマジックナンバー
//    ^ SortingOption | undefined`型になる(noUncheckedIndexedAccess が有効の場合)
// see: https://azukiazusa.dev/blog/typescript-no-unchecked-indexed-access/

const defaultOption_2 = sortingOptions.find((it) => it.id === "priceAsc") 
//    ^ SortingOption | undefined`型になる

sortingOptions が配列形式だと、このようになります。

defaultOption_1 では、マジックナンバー 1 が登場しています。少ない背景情報でコードを理解できるようにするのが目的なのに、マジックナンバーを使ってしまったら意味が無くなってしまいますよね?

defaultOption_2 では、find を使って 動的に 該当オブジェクトを抽出する羽目になります。「『価格が安い順』を使う」ということは 静的に決まっている ことなのに、わざわざ動的な抽出ロジックを書くのは、ミスの混入リスクがあるし、避けたいですよね?

そもそも、どちらの場合でも、(_1 はオプション次第ですが)| undefined が出てくるので、余計なノイズになってしまいます。

https://azukiazusa.dev/blog/typescript-no-unchecked-indexed-access/


そのような事態を回避するために、連想配列を使っています。

「選択肢の既定値は、ソート選択肢のうち priceAsc である」と、そのまんま読めます。簡単でしょ?

✅ sortingOptions が連想配列形式の場合
const defaultOption = sortingOptions.priceAsc

もう一つ嬉しい点があります。 sortingOptionsas const を付けているので、priceAsc にマウスホバーすれば(as const のお陰でリテラル型として決まった)「狭い」型情報が表示されます。

これによって、どの選択肢を使うか静的に決定されている 場合であれば、エディタ上で定数の情報をうかがい知ることが可能になるのです。

 の  にマウスホバーして、id が"priceAsc"、 sort が "price"、order が "asc"、 label が "価格が安い順" であることが表示されている状態
ホバーしただけで、定数の中身が見える

🤔 それでも id が 2回ずつ出てくるのは DRY じゃないよね…?

それは確かにそうです。ただ、「近くにあるから、凡ミスをしても気づきやすく、修正もしやすい」し、選択肢からidを取得するロジックで凝ったことをする必要が無くなるので妥協しました。

どうしても納得できないなら、いい感じのユーティリティを書く方法があるかもしれません。

1-2. Q: as const satisfieskeyof typeof って何をやってるの?

const options = { 
  //... 
} as const satisfies Record<string, OptionType>;

type Id = keyof typeof options

というイディオム(仮に『satisfies-any-keyof パターン』と呼びます)を使うことで、《定義を列挙する連想配列 sortingOptions に、型情報も含め全ての情報が集約されている》コードを実現しています。

SortingOptionId は、この連想配列に基づいた(keyof typeof 演算子による)導出によって "priceDesc" | "priceAsc" | "ratingDesc" | "ratingAsc" という型に決定されるのです。

下のコードは、satisfies-any-keyof パターン を活用しなかった場合の様子です。

「ユニオン型の宣言」/「連想配列による定義の列挙」に情報が分かれてしまっていますね?

🔺 satisfies-any-keyof パターンを活用しなかった場合
export type SortingOptionId =
  | "priceDesc"
  | "priceAsc"
  | "ratingDesc"
  | "ratingAsc";

export const sortingOptions: Record<SortingOptionId, SortingOption> = {
  priceDesc: { /* ... */ },
  printAsc: { /* ... */ },
  ratingDesc: { /* ... */ },
  ratingAsc: { /* ... */ },
};    

元のコードと、上の🔺例を比較すると、 satisfies-any-keyof パターン では、《連想配列 sortingOptions に列挙されている「選択肢」の定義のいずれか》の形で SortingOptionId を導出するようになっているので、コードの意味が読み取りやすくなっていると思います。

✅ satisfies-any-keyof パターンを活用した場合
/** 「商品一覧」ページの「ソート順」の選択肢をまとめた SSoT */
export const sortingOptions = {
  priceDesc: { /* ... */ },
  priceAsc: { /* ... */ },
  ratingDesc: { /* ... */ },
  ratingAsc: { /* ... */ },
} as const satisfies Record<string, SortingOption>;

/** {@link SortingOption} の id が取りうる値のユニオン型。*/
export type SortingOptionId = keyof typeof sortingOptions;

satisfies についての解説は、こちらも参考になります。(一部「配列形式」のコード例が含まれているので、そこだけ注意!)

https://zenn.dev/tonkotsuboy_com/articles/typescript-as-const-satisfies

2. 単純な逆引きロジックを同じファイルに配置する

2-1. id から逆引き

文字列型の id から逆引きして、SortingOption を取得するロジックを見てみましょう。

// SortingOption の一意の id -> SortingOption のマッピングのためのデータ置き場
const idOptionMap = new Map<string, SortingOption>(
  Object.values(sortingOptions).map((v) => [v.id, v]),
);

/**
 * `id` から逆引きで {@link SortingOption} を取得する。
 * 見つからない場合は undefined を返す。
 */
export const getSortingOptionById = (id: string): SortingOption | undefined =>
  idOptionMap.get(id);

🤔 id から SortingOption を取得するだけなら、sortingOptions[optionId] だけで十分じゃないか?

と疑問に思うかもしれません。しかし、それだけだと、型の厳密さによって問題が起こる可能性が高いです。

sortingOption[optionId] という式は、

  • ✅️ optionsId が「厳密で狭い型」のときにはコンパイルが通りますが、
    • 例: SortingOptionId つまり "priceDesc" | "priceAsc" | "ratingDesc" | "ratingAsc"
  • ⚠️ optionsId が「広い型」の場合には、コンパイルエラーになります。
    • 例: string
    • このとき、as SortingOptionId のようにコンパイラを黙らせることになります。

広い型の例を一つ挙げると、select の onChange イベントハンドラ内の処理があります。

❌️コンパイルエラーになる場合
// <select onChange={(e) => {
const optionId = e.target.value;
//    ^ string 型
const option = sortingOptions[optionId]; 
//                            ^ ここでエラー
✅ コンパイルエラーにならない場合
// <select onChange={(e) => {
const optionId = e.target.value;
//    ^ string 型
const option = getSortingOptionById(optionId);
//    ^ SortingOption | undefined 型になる
if(!option) return;

このように「厳密で狭い」型にしか対応していない場合は、色々と不便です。必要・可能であれば「広い」型を受け付ける逆引き関数も用意しておきましょう。

2-2. { sort, order } の組から逆引き

たいていの場合、クエリパラメータを読み取って「今、どの選択肢を選んでいるか」の状態に反映するロジックが必要になりますが、そのためには、sort order の組から SortingOption を取得するロジックが必要です。

// SortingOption の key と order から SortingOption を取得するためのデータ置き場
const options = Object.values(sortingOptions);

/**
 * `{ sort, order }` の組から、逆引きで {@link SortingOption} を取得する。
 * 見つからない場合は undefined を返す。
 */
export const getSortingOption = (
  sortOption: Pick<SortingOption, "sort" | "order">,
): SortingOption | undefined => {
  const { sort, order } = sortOption;
  return options.find((it) => it.sort === sort && it.order === order);
};

🤔 クエリパラメータから選択肢を逆引きするなら、URLSarchParams や ParsedUrlQuery のようなオブジェクトから直接読み取れば良いんじゃない?

と思われるかもしれません。

getSortingOption の引数として Pick<T, K> ユーティリティ型を使っていることから分かる通り、いったん「具体的なクエリパラメータの仕様」から離れて、純粋に「SortingOptionsortorder に基づいた逆引き」に着目しているのです。

こうすることで、「クエリパラメータから逆引きする」側のロジック(3. の章で示す処理)が簡潔で読解しやすくなるだけでなく、getSortingOption がクエリパラメータの仕様から分離されて左右されづらくなります。


「クエリパラメータの仕様から分離されている」ことは《他の一覧ページに同様のソート機能があり、コードを一部共通化しようとしたとき》に役立つ可能性があります。クエリパラメータの仕様は、画面によって大きく異なる可能性 が十分に考えられるからです。

2-3. Q: なぜ関数なのに、定数と一緒にまとめてるの?関心の分離は?

余談になりますが、

🤔 なぜ、getSortingOption などの関数を、sortingOptions と一緒にまとめているの?

と疑問に思われるかもしれません。

私は、むしろ、そのような実装を 「関心の離散」名付けて、アンチパターンと捉えています。 両者を分けるべきではない、と考えます。

もっと踏み込むと、「どのファイルがどこにあるか分からない」混乱を避けるためには、「定数」「関数」「型宣言」のような「何を使って実装しているか」に囚われるべきではなく、

「データ取得・更新」「ロジックからフレームワーク依存の部分を抜いたもの」のような、「ソフトウェアとしての構造の中で、どのような位置づけなのか」に着目して分割・分類すべき だと思います。

https://qiita.com/honey32/items/dbf3c5a5a71636374567

3. クエリパラメータと「選択肢」間の相互変換処理

最後に、クエリパラメータと「選択肢」間の相互変換処理を見てみましょう。

この部分は、使用しているフレームワーク・Server Component か Client Component かによって異なる部分です。

  • React / Next.js (App Router) を使用
  • Page コンポーネント(Server Component)で読み取り
  • 子として切り出された Client Component で読み取り / 書き換えの両方

今回の例では、以上のようなポイントで、選択肢とクエリパラメータが交わることになります。

  • URLSearchParams からの読み取り… getSortingOption_URLSearchParams
  • URLSearchParams への書き換え… setSortingOption_URLSearchParams
  • ParsedUrlQuery からの読み取り… getSortingOption_ParsedUrlQuery

そのために、以上の3つの関数を用意しています。

加えて、クエリパラメータが空あるいは不正値の場合に、既定の「価格が安い順」にフォールバックするのも、これらの関数の責務に含めています。

さっきの章で述べたように、このファイルは「純粋ではない、具体的なページ設定」に近い立ち位置にしているので、そのような処理も含めて良いでしょう。(もちろん、仕様がさらに複雑な場合には、呼び出し方から明示的に指定するようにしても良いかもしれませんが…)

app/search-items/_query-params/sorting.ts
import type { ParsedUrlQuery } from "node:querystring";
import { getSingleQueryParam } from "@honey32/next-query-utils";

import { withDefault } from "@/app/_utils/with-default";
import {
  type SortingOption,
  getSortingOption,
  sortingOptions,
} from "../__models/misc/sorting";

/** 選択肢の既定値。 */
export const defaultSortingOption = sortingOptions.priceAsc;

/**
 * {@link URLSearchParams} から {@link SortingOption} を取得する。
 * 該当するものが無い場合は、{@link defaultSortingOption} を返す。
 */
export const getSortingOption_URLSearchParams = withDefault(
  (query: URLSearchParams): SortingOption | undefined => {
    const sort = query.get("sort");
    const order = query.get("order");

    if (!sort || !order) return undefined;
    return getSortingOption({ sort, order });
  },
  defaultSortingOption,
);

/**
 * {@link URLSearchParams} を、与えられた {@link SortingOption} で破壊的に更新する
 */
export const setSortingOption_URLSearchParams = (
  it: URLSearchParams,
  option: Pick<SortingOption, "sort" | "order">,
): void => {
  it.set("sort", option.sort);
  it.set("order", option.order);
};

/**
 * {@link ParsedUrlQuery} から {@link SortingOption} を取得する
 * 該当するものが無い場合は、{@link defaultSortingOption} を返す。
 */
export const getSortingOption_ParsedUrlQuery = withDefault(
  (query: ParsedUrlQuery): SortingOption | undefined => {
    const sort = getSingleQueryParam(query, "sort");
    const order = getSingleQueryParam(query, "order");

    if (!sort || !order) return undefined;
    return getSortingOption({ sort, order });
  },
  defaultSortingOption,
);

これらの関数のお陰で、コンポーネント側の《クエリパラメータの読み書きのためのコード》が短縮され、かつ「選択肢」の管理が一元化されているので、コードの保守性が向上すると思います。

これらの関数を使用する、コンポーネント側のコードは以下のようになります。

app/search-items/page.tsx
const Page: FC<Props> = async ({ searchParams }) => {
  const option = getSortingOption_ParsedUrlQuery(searchParams);
app/search-items/sort-select.tsx
export const SortSelect: FC<Props> = ({ className }) => {
  // 中略
  const searchParams = useSearchParams();
  const sorting = getSortingOption_URLSearchParams(searchParams);
  // 中略

  // return <select onChange={(e) => {
    // 中略
    const newSearchParams = toUpdatedSearchParams(
      searchParams, //
      (it) => setSortingOption_URLSearchParams(it, option),
    );
    router.push(`?${newSearchParams.toString()}`);

4. ディレクトリ構造

ディレクトリ構造は、以下のようになっています。

  • 🗂️ app/
    • 📁 _utils/
      … アプリ全体で共通のユーティリティ群
      • 📁 search-params/
        • update.ts
      • with-default.ts
    • 📁 search-items/
      … 同名のルート(App Router)にまつわるファイル群
      • 📁 __models/
        • misc/
          • sorting.ts
            … 選択肢の定義と逆引きロジック
      • 📁 _fetchers/
        … Server Component 用のデータ取得関数
      • 📁 _query-params/
        … クエリパラメータまわりの、ページ固有の抽出されたロジック
        • sorting.ts
      • page.tsx … ページ本体(App Router の規約)
      • search-result.tsx
        … 取得したデータの表示を切り出したコンポーネント
      • sort-select.tsx
        … ソート選択のセレクトボックス(Client Component として切り出し)

定数(および単純な逆引きロジック)を入れた sorting.ts は、__models/ というディレクトリに配置しています。かなり迷いましたが、「データそのものの定義」ではないものの、「データの取得に使う、データに準じる規則」のようなモノなので、misc ディレクトリを切ってその中に配置しました。

クエリパラメータと「選択肢」間の相互変換処理を入れた sorting.ts は、_query-params/ というディレクトリに配置しています。これは、_fetchers/ と同じく、コンポーネントのロジックをの一部を抽出したものを入れるディレクトリです。

ディレクトリ名にアンダーバー _ を付けるのは、App Router の Privete Folder という規約です。そのような名称のディレクトリはルーティングの対象外になります。

App Router でないプロジェクトであっても、このディレクトリが「副次的なもの」であることが、ファイル一覧上の順番によって明瞭になるのでオススメです。

https://nextjs.org/docs/app/building-your-application/routing/colocation#private-folders

__models/ にアンダーバーを2つも付けているのは、僕の勝手なアイデアですが、ファイル一覧で _fetchers/ _query-params/ のようなアンダーバー1つのディレクトリよりも上に来るようにするためです。これによって、_query-params/__models/ のようなディレクトリ間の単一方向の依存関係が一目瞭然になる と思います。

補遺1. セレクトボックスを組み立てるのに Object.values() を使わない

セレクトボックスを組み立てる際には、Array.prototype.map() メソッドを使って <option /> たちを描画するために配列を作ります。

このとき、個人的なこだわりとして、Object.values() を使わず以下のように冗長な書き方をすることが多いです。

app/search-items/sort-select.ts の一部抜粋
// select に、この順番で表示する。
const options = [
  sortingOptions.priceDesc,
  sortingOptions.priceAsc,
  sortingOptions.ratingDesc,
  sortingOptions.ratingAsc,
];

// 中略

// ↓ 読みやすさのため、一部の Props を省略しています。
<select>
  {options.map((option) => (
    <option key={option.id} value={option.id}>
      {option.label}
    </option>
  ))}
</select>

Object.values は選択肢の順番が保証されているとはいえ、何だか不安定な感じがするので、個人的には、上記のように明示的に選択肢の配列を作成する ほうが好みです。

また、これまでにも登場している「具体的な仕様から、純粋な部分を抽出して分離する」考え方にも合致していると思います。

「ありえる全ての選択肢についての純粋な定義」と、「『セレクトボックスに表示される選択肢』という具体的な仕様」に分けられていて、このコンポーネントのファイル sort-select.ts の記述は後者にあたります。

まとめ

この記事では、ソート条件のような「選択肢」の定義 を高凝集にまとめたファイルを使って、クエリパラメータやセレクトボックスにまつわるコードとその依存関係を簡潔にする方法について、アイデアをまとめました。

ぜひ、この記事を参考にして、皆さんのプロジェクトに適用してみてください。

それに留まらず、それぞれの章や節の内容を応用すれば、定数にとどまらず、React でコードをクリーンに保つためのヒントが得られると思います。

株式会社ゆめみ

Discussion

tobigumotobigumo

選択肢の一覧を得る際はやはりObject.valuesでしょうか。
ループでの捜査順がいまはしっかり定義順になるのは分かってはいるのですが、
選択肢の一覧の順序を指定したい場合は配列のほうがいいのでは?と、
いつもObjectと配列で迷っています

Honey32Honey32

記事で言及するのをすっかり忘れていました!ご質問ありがとうございます!

isOptionMap を構築するときには順序が関係ないので Object.values() を使っていますが、それ以外の箇所では、使わないほうが良いと思います。

たとえば、sort-select.tsx では select に表示する選択肢たちを配列にしますが、そこでは Object.values() を使わず、 直書きの sortingOptions. を列挙しています。

少しぐらい冗長になったとしても、「select にどの選択肢が、どのように並ぶか」はそのコンポーネントの中で明示的に書くべきものだから、という考えでそうしています。

https://github.com/honey32/next-enum-const/blob/da933f3810004a8bbff6df43d2c72a2233bfba7c/app/search-items/sort-select.tsx#L34-L40

レオレオ

id からの逆引きについて、自分の場合は(好みですが)専用の narrowing 関数を定義するようにしています。
これにより型安全性を保って sortingOptions[optionId] を取得することができます。

参考: Using type predicates (typescriptlang.org)

app/search-items/__models/misc/sorting.ts
/**
 * 引数の値が {@link SortingOptionId} かどうかを判定する
 */
// 返り値の型に type predicate を使用
export function isSortingOptionId(value: string): value is SortingOptionId {
  // ここで誤った結果を返しても型エラーにならないので注意
  return Object.keys(sortingOptions).includes(value);
}
function onChange(e: { target: { value: string }}) {
  const optionId = e.target.value;
  //    ^ string 型
  if (!isSortingOptionId(optionId)) {
    return;
  }
  const option = sortingOptions[optionId];
  //                            ^ SortingOptionId 型になっている
  //    ^ SortingOption 型になる (エラーにならない)
}