見出し画像

fp-tsのOption型とts-patternを使用したTypeScriptの実践テクニック~ルーティングデータをもとに~

こんにちわ。nap5です。

fp-tsのOption型とts-patternで実現するTypeScriptの実践テクニックの紹介です。ルーティングデータをもとにしています。


まずはもととなるルーティングデータになります。

export type Route = {
  href: string;
  title: string;
  description: string;
};

export const routes: Route[] = [
  {
    href: "prologue",
    title: "プロローグ: 新たなる挑戦へ",
    description: "新たな挑戦が始まります。宇宙への夢と期待を胸に、壮大な冒険の序章が開かれます。"
  },
  {
    href: "uchiage-junbi-engine-to-koko-system",
    title: "1章: 打ち上げ準備 - エンジンと航行システム",
    description: "打ち上げに向けての準備が進められます。エンジンと航行システムに焦点を当て、技術的な詳細を探求します。"
  },
  {
    href: "uchu-e-no-tabidachi-tsuki-e-no-michinori",
    title: "2章: 宇宙への旅立ち - 月への道のり",
    description: "宇宙への旅が始まります。月への道のりと、宇宙船内での生活について詳しく探ります。"
  },
  {
    href: "getsu-men-chakuriku-rekishiteki-shunkan",
    title: "3章: 月面着陸 - 歴史的瞬間",
    description: "月面着陸とその歴史的瞬間に焦点を当て、人類の偉大な成果を讃えます。"
  },
  {
    href: "epilogue",
    title: "エピローグ: 宇宙探査の未来へ",
    description: "宇宙探査の未来に思いを馳せ、この壮大な旅が私たちに何を教えてくれたのかを振り返ります。"
  }
]


次に前後のナビデータを付与したデータを取得するgetDataWithPrevNext関数になります。この関数を通すと、undefinedを得ることができるので、今回のデモでのユースケースにマッチします。

import { Path } from "dot-path-value";
import { mutateWithSummary, tidy } from "@tidyjs/tidy";

type Props<T extends Record<string, unknown>> = {
  id: Path<T>,
  n?: number
}

export const getDataWithPrevNext = <T extends Record<string, unknown>>(
  data: T[],
  options: Props<T>
) => {
  const { id, n = 1 } = options
  const outputData = tidy(
    data,
    mutateWithSummary({
      prev: (data) => data.map((d, i) => {
        const item = (i >= n ? data[i - n] : null)
        if (item == null) return
        return item[id]
      }),
      next: (data) =>
        data.map((d, i) => {
          const item = (i + n < data.length ? data[i + n] : null)
          if (item == null) return
          return item[id]
        }),
    })
  );
  return outputData;
};


出力結果は以下になります。

import { describe, test, expect } from "vitest";

import { routes } from "@/config/route";
import { getDataWithPrevNext } from ".";

describe("getDataWithPrevNext", () => {
  test("href -> Window Size=1", () => {
    const inputData = routes;
    const outputData = getDataWithPrevNext(inputData, { id: "href", n: 1 });
    expect(outputData).toStrictEqual([
      {
        href: "prologue",
        title: "プロローグ: 新たなる挑戦へ",
        description:
          "新たな挑戦が始まります。宇宙への夢と期待を胸に、壮大な冒険の序章が開かれます。",
        prev: undefined,
        next: "uchiage-junbi-engine-to-koko-system",
      },
      {
        href: "uchiage-junbi-engine-to-koko-system",
        title: "1章: 打ち上げ準備 - エンジンと航行システム",
        description:
          "打ち上げに向けての準備が進められます。エンジンと航行システムに焦点を当て、技術的な詳細を探求します。",
        prev: "prologue",
        next: "uchu-e-no-tabidachi-tsuki-e-no-michinori",
      },
      {
        href: "uchu-e-no-tabidachi-tsuki-e-no-michinori",
        title: "2章: 宇宙への旅立ち - 月への道のり",
        description:
          "宇宙への旅が始まります。月への道のりと、宇宙船内での生活について詳しく探ります。",
        prev: "uchiage-junbi-engine-to-koko-system",
        next: "getsu-men-chakuriku-rekishiteki-shunkan",
      },
      {
        href: "getsu-men-chakuriku-rekishiteki-shunkan",
        title: "3章: 月面着陸 - 歴史的瞬間",
        description:
          "月面着陸とその歴史的瞬間に焦点を当て、人類の偉大な成果を讃えます。",
        prev: "uchu-e-no-tabidachi-tsuki-e-no-michinori",
        next: "epilogue",
      },
      {
        href: "epilogue",
        title: "エピローグ: 宇宙探査の未来へ",
        description:
          "宇宙探査の未来に思いを馳せ、この壮大な旅が私たちに何を教えてくれたのかを振り返ります。",
        prev: "getsu-men-chakuriku-rekishiteki-shunkan",
        next: undefined,
      },
    ]);
  });
});


今回はこのデータをもとにNullishが発生するコンテキストでのプロセスをハンドリングしていきます。


まずはOption型を使わない場合から。この関数では現在のHrefを含む前後のルーティングデータを取得する目的で作成しております。

/**
 * Optionを使わない場合
 * @param href
 * @returns
 */
export const findRoute = (href: string) => {
  const currentHref = href.startsWith("/") ? href.slice(1) : href;
  const currentRoute = routesWithPrevNext.find((d) => d.href === currentHref);
  if (currentRoute == null) return;
  const { prev, next } = currentRoute;
  const prevRoute = routesWithPrevNext.find((d) => d.href === prev);
  const nextRoute = routesWithPrevNext.find((d) => d.href === next);

  return {
    prevRoute,
    currentRoute,
    nextRoute,
  };
};


次にOption型を使う場合です。fp-tsだと型ガードがやや難しく、ts-patternなどの力を借りています。結構溶け込んでいるので、ワークアラウンドとしてはハンディでいい感じです。

/**
 * Optionを使う場合
 * @param href
 * @returns
 */
export const findRouteWithOption = (href: string) => {
  const currentHref = href.startsWith("/") ? href.slice(1) : href;
  return pipe(
    getRoute(currentHref),
    O.flatMap((d) =>
      // @see https://github.com/gcanti/fp-ts/discussions/1565#discussioncomment-1227291
      match(d)
        .with(P.nullish, () => O.none)
        .otherwise((d) =>
          pipe(
            O.of(d),
            O.bindTo("currentRoute"),
            O.bind("prevRoute", ({ currentRoute }) =>
              pipe(O.fromNullable(currentRoute.prev), O.flatMap(getRoute))
            ),
            O.bind("nextRoute", ({ currentRoute }) =>
              pipe(O.fromNullable(currentRoute.next), O.flatMap(getRoute))
            )
          )
        )
    )
  );
};


それぞれのfindRoute関数のテストコードは以下になります。

import { describe, test, expect } from "vitest";
import { findRoute, findRouteWithOption } from ".";
import { pipe } from "fp-ts/lib/function";
import * as O from "fp-ts/lib/Option";

describe("findRouteWithOption", () => {
  test("head", () => {
    const currentHref = "/prologue";
    const result = pipe(currentHref, findRouteWithOption);
    if (O.isNone(result)) return;
    const { prevRoute, currentRoute, nextRoute } = result.value;
    expect({ prevRoute, currentRoute, nextRoute }).toStrictEqual({
      prevRoute: undefined,
      currentRoute: {
        href: "prologue",
        title: "プロローグ: 新たなる挑戦へ",
        description:
          "新たな挑戦が始まります。宇宙への夢と期待を胸に、壮大な冒険の序章が開かれます。",
        prev: undefined,
        next: "uchiage-junbi-engine-to-koko-system",
      },
      nextRoute: {
        href: "uchiage-junbi-engine-to-koko-system",
        title: "1章: 打ち上げ準備 - エンジンと航行システム",
        description:
          "打ち上げに向けての準備が進められます。エンジンと航行システムに焦点を当て、技術的な詳細を探求します。",
        prev: "prologue",
        next: "uchu-e-no-tabidachi-tsuki-e-no-michinori",
      },
    });
  });
});

describe("findRoute", () => {
  test("head", () => {
    const currentHref = "/prologue";
    const result = findRoute(currentHref);
    if (result == null) return;
    const { prevRoute, currentRoute, nextRoute } = result;
    expect({ prevRoute, currentRoute, nextRoute }).toStrictEqual({
      prevRoute: undefined,
      currentRoute: {
        href: "prologue",
        title: "プロローグ: 新たなる挑戦へ",
        description:
          "新たな挑戦が始まります。宇宙への夢と期待を胸に、壮大な冒険の序章が開かれます。",
        prev: undefined,
        next: "uchiage-junbi-engine-to-koko-system",
      },
      nextRoute: {
        href: "uchiage-junbi-engine-to-koko-system",
        title: "1章: 打ち上げ準備 - エンジンと航行システム",
        description:
          "打ち上げに向けての準備が進められます。エンジンと航行システムに焦点を当て、技術的な詳細を探求します。",
        prev: "prologue",
        next: "uchu-e-no-tabidachi-tsuki-e-no-michinori",
      },
    });
  });
});


最後にデモコードです。


簡単ですが、以上です。

この記事が気に入ったらサポートをしてみませんか?