複数プロダクトのコードレビューで見えてきた課題を解消する社内パッケージ @itandi/tools

はじめに

こんにちは!イタンジ株式会社のFrontendチームに所属している西野です。

Frontendチームでは月100件以上、複数プロダクトのコードレビューを担当しています。

レビューを重ねる中で、多くのプロダクトに共通する課題が見えてきました。たとえば、「日付のフォーマットが統一されていない」や「Next.jsのPages Routerの仕様に苦しめられている」などです。同様の処理が各プロダクトで個別に実装されていたり、同じ指摘を繰り返したりしていました。

そこで、重複している処理を共通パッケージとして整備すれば課題を解決できるのでは?と思い、誕生したのが @itandi/tools です。

@itandi/tools とは

@itandi/tools によって以下のことができます。

  1. 日付表記を共通関数で統一する
  2. プレーンテキストの描画変換を共通化する
  3. パスを型安全に管理する
  4. router.query を型安全に扱う

ここからは各機能の詳細を紹介します。

日付表記を共通関数で統一する

ITANDIでは、それぞれのプロダクトで日付のフォーマットに date-fnsmoment が使われていました。

ITANDIで扱っている日付は Date だけでなく文字列や null になることがあり、それらを考慮しながらフォーマットする必要がありました。

その結果、日付の表記揺れ(曜日があったりなかったり、区切り文字が統一されていなかったり)や、fallbackの処理(日付がないときに代わりの文字列を出力する)のためにコードが少し冗長になったりしていました。

そこで formatDateformatDateRange などの関数を定義することで、複数のプロダクト間で一貫した表記で日付を表示しています。

formatDate(new Date("2026-01-15")) //=> "2026/01/15(木)"
formatDate("2026-01-15") //=> "2026/01/15(木)"
formatDate(null) //=> ""
formatDate(null, "未設定") //=> "未設定"

formatDateRange(new Date("2026-01-01"), new Date("2026-01-31")) //=> "2026/01/01(木) 〜 2026/01/31(土)"
formatDateRange(new Date("2026-01-01"), null) //=> "2026/01/01(木) 〜"
formatDateRange(null, null) //=> ""
formatDateRange(null, null, "期間未設定") //=> "期間未設定"

プレーンテキストの描画変換を共通化する

ITANDIでは、ユーザー同士がチャットで会話することがあります。 チャットの内容はプレーンテキストで保存されていますが、ユーザーの画面には改行をそのまま表示したり、URLをデザインシステムのリンクにして描画したりしたいです。 しかし、Reactにプレーンテキストをそのまま渡しても、意図した描画にはなりません。

renderText は改行文字列を <br> に変換、また、テキスト内のURLを任意の ReactNode に変換できます。

import { TextLink } from "@itandi/itandi-bb-ui/TextLink";

renderText("以下のURLを確認してください\nhttps://example.com/", {
    url: (url) => <TextLink href={url}>{url}</TextLink>
})
//=> <>以下のURLを確認してください<br/><TextLink href="https://example.com/">https://example.com/</TextLink></>

パスを型安全に管理する

Next.jsで router.push などにパスを直接書くと、存在しないパスへの遷移をTypeScriptが検出できず、実行時まで気づけないという問題があります。

そこで createGeneratePath() でアプリのパスを型パラメータとして定義することで、パスを型安全にしました。

また、動的セグメントに対して、パラメーターを渡すこともできます。

const generatePath = createGeneratePath<"/users/:id" | "/posts/:postId">();
generatePath("/users/:id", { id: 1 }) //=> "/users/1"
generatePath("/posts/:postId", { postId: "abc" }) //=> "/posts/abc"
generatePath("/users/:id") //=> "/users/:id"

generatePath("/invalid/:path", { path: "x" }); // Argument of type '"/invalid/:path"' is not assignable to parameter of type '"/users/:id" | "/posts/:postId"'.
generatePath("/users/:id", { postId: "abc" }) // Object literal may only specify known properties, and 'postId' does not exist in type '{ id: string | number; }'.

執筆時点でNext.jsでは typedRoutes というオプションが使えます。

しかし、Pages Routerの router.pushrouter.replace などに型が効かなかったり、Next.jsのファイルシステムルーティングにのみ対応しているためAPIエンドポイントのパスなど、Next.js以外のパスは管理できなかったりします。

ITANDIではAPIエンドポイントはRuby on Railsで作成しているため、createGeneratePath を使うことでページ・APIエンドポイントのパスをまとめて管理できます。

また、生成したパスは useSWRkey としてもそのまま使えます。これにより、APIリクエストのパスとSWRキーを同じ関数で一元管理でき、定義されていないパスを引数に渡した際もTypeScriptがエラーを出してくれます。

const generateApiPath = createGeneratePath<
    "/api/users/:id" | "/api/posts/:postId"
>();
const apiPath = generateApiPath("/api/users/:id", { id: 1 });
const { data } = useSWR(apiPath, (url) =>
    fetch(url).then((res) => res.json())
); // key: "/api/users/1"

router.query を型安全に扱う

ITANDIではNext.jsをPages Router + SSGで運用しています。

SSGではありますが、大半がログイン後のページであるため、静的に生成されているページは少なく、ほとんどのページが実質CSRで描画されています。 そのため実行時に router.isReadyfalse になることはほぼありません。 router.query.id の型は string | string[] | undefined ですが、/[...slug] のようなパスはなく、router.query.id の型は実質 string になります。 また、URLクエリパラメータは nuqs を推奨しているため、router.query から取得されることは想定していません(つまり、動的パスセグメントのみ)。 上記に対して各プロダクトでは String(router.query.id) のようにして、無理矢理 string にキャストしていました。

それに対し、以下のようなコンポーネントとHookを作ることで、router.query を型安全に扱えるようにしました。

  • <ReadiedRoute />
    • childrenrouter.isReady === true の状態でのみ描画するコンポーネント
  • useReadiedQuery
    • <ReadiedRoute> 内で使用する型安全に router.query を扱えるHook
import { ReadiedRoute, useReadiedQuery } from "@itandi/tools/next";
import { parsePositiveInteger } from "@itandi/tools/vanilla";
import { ErrorBoundary } from "react-error-boundary";

function Child() {
    const id = useReadiedQuery("id", (q) => {
        const id = parsePositiveInteger(q);

        if (id === null) {
            throw new Error("id is null.");
        }

        return id;
    });

    return <div>{id}</div>;
}

export default function Page() {
    return (
        <ErrorBoundary fallback={...}>
            <ReadiedRoute>
                <Child />
            </ReadiedRoute>
        </ErrorBoundary>
    );
}

parsePositiveInteger は任意の入力値が正の整数かどうかを判定して返す関数です。 id が正の整数でない場合(無効な id であると推測される場合)、エラーがthrowされ、<Child> は描画されず fallback(エラーページなど)が描画されます。 有効な id である場合、<Child> が描画され、<Child> 内では idnumber として扱えます。 これにより、id が不正な場合の無駄なAPIリクエストを防げ、また、動的セグメントの値を型安全に扱えます。

おわりに

@itandi/tools は、単に便利な関数やコマンドを集めたパッケージではなく、ITANDIの共通課題を解決するためのパッケージです。

課題を解決することで、実装の重複を減らせ、また、依存パッケージも共通化され、整理されます。

それによって、各プロダクト開発のエンジニアはフロントエンドの技術課題に迷うことがなく、「プロダクトをどう良くするか」に注力できると考えています。

今後も各プロダクトのフロントエンドを横断的に支えるチームとして、@itandi/tools を含むフロントエンド基盤全体を強化していきます。

採用情報

Frontendチーム、絶賛採用中です!カジュアル面談もお待ちしています!