🐸

Result型 + Higher-Order FunctionでPromiseを使い易くする【TypeScript】

2024/05/16に公開

Promiseの煩雑さ

(Node.jsの世界でも頻繁にPromiseは登場しますが)フロントエンドでは、画面の描画時やイベント時にAPIへのリクエスト〜レスポンスデータの取得、ルーターでの画面遷移などで頻繁にPromiseを扱います。

Promiseの状態解決をした後に.thenメソッド等で、後続処理を書いていくことになりますが、
手続き的に後続処理を書き繋ぐと際限なくネストが深くなり続け、Promise地獄になってしまうことがあります。(特にPromise.allなど)

.then((response) => {
// Promise.allなどで大量にPromise.resolveされた値を受け取る
// responseを加工する処理や加工した値をどこかにコピーする処理を手続き的に処理する
// ネストが深くなっていく
})

またPromiseの状態解決後に後続で書かれる処理は、”決め”としてサービスの仕様が固まっている場合、似たような処理であることも多く、ファイルごとや実装者によって対応のバラつきが起こってしまうと負債や実装不備となってしまいます。

また仕様が変わった際に、至る箇所に散りばめられた類似処理に対して修正を行うことになってしまいます。そうなる前にできるだけいい感じに設計できないか?と考え始めました。

これらを解決する1つの方法としては、 Result型Higher-Order Function(高階関数) を活用することです。

Result型(Either型)

  • 例外処理される可能性がある関数の戻り値をSuccess<T> | Failure<E>として扱う実装パターンです。
  • 以下の例ではtagsuccess | failureのユニオン型となることで、例えばPromiseresject(失敗)を返すときresult.tag === 'failure'とタイプガードすることで、安全にerrorsプロパティにアクセスでき、扱いやすいインタフェースとなります。

プロパティtagとしている理由は、成功と失敗の状態を識別する文字列リテラル(success | failure)をユニオン型で持つことで、TypeScriptの型システムの恩恵を受けるためです。

type Success<T> = {
  tag: 'success'
  result: T
}
type Failure<E> = {
  tag: 'failure'
  errors: E
}
export type Result<T, E> = Success<T> | Failure<E>

Higher-Order Function(高階関数)とは

Higher-Order Function(高階関数) とは、他の関数を引数として受け取ったり、関数を結果として返す関数のことを指します。
関数型プログラミングでは、高階関数は一般的なデザインパターンであり、コードの抽象化や再利用性を向上させることができます。

この2つの特徴を組み合わせて、Result型 + PromiseのHigher-Order Function(高階関数) を実装してみます。

Result型 + Promiseの高階関数(fromPromiseAsync)

上記のResult型を使って、Promoseの高階関数を作成します。

Promiseを返す関数を第一引数として受け取り、その関数の引数を第二引数に受け取るカリー化を採用します。再利用性を高めるためですが、引数は F型のParameters<F>を取っているため型の安全性は担保されています。

import type { Result } from "../types"

type FromPromiseAsync = <F extends (...args: any) => Promise<any>>(
  func: F
) => (
  ...args: Parameters<F>
) => Promise<Result<ReturnType<F>, unknown>>

export const fromPromiseAsync: FromPromiseAsync =
  (func) =>
  async (...args) => {
    try {
      const result = await func(...args)
      return { tag: 'success', result }
    } catch (error: unknown) {
      return { tag: 'failure', errors: error }
    }
  }

これによりFromPromiseAsyncに入力されたPromise関数の戻り値はResult型として扱われます。

簡単なPromise関数でテストします。

  it('引数をstringで受け取り、Promise.resolveを返す', async() => {

    const asyncFunction = (name: string) => {
      return new Promise((resolve) => {
        resolve({ name })
      })
    }

    const curryFromPromiseAsync = fromPromiseAsync(asyncFunction)
    const result = await curryFromPromiseAsync('taro yamada')

    expect(result).toStrictEqual({ tag: 'success', result: { name: 'taro yamada' }})
  })

  it('引数を受け取らず、Promise.rejectを返す', async() => {

    const asyncFunction = () => {
      return new Promise((resolve, reject) => {
        reject({ error:'error' })
      })
    }
    const curryFromPromiseAsync = fromPromiseAsync(asyncFunction)
    const result = await curryFromPromiseAsync()

    expect(result).toStrictEqual({ tag: 'failure', errors: { error: 'error' }})
  })

次に Promise.all で並列処理をします。

 it('直列処理するPromise.allで、Promise.resolveを返す', async() => {

    type Parameters = { name: string, age: number }

    const asyncAllFunction = ({ name, age }: Parameters) => {
      return Promise.all([
        Promise.resolve({ name }),
        Promise.resolve({ age })
      ])
    }

    const curryFromPromiseAsync = fromPromiseAsync(asyncAllFunction)
    const result = await curryFromPromiseAsync({ name: 'taro yamada', age: 20 })

    expect(result).toStrictEqual({ tag: 'success', result: [{ name: 'taro yamada' }, { age: 20 }]})
  })

router.pushの高階関数をfromPromiseAsyncから作成する

上記でPromise関数をResult型で返す高階関数(fromPromiseAsync)を作成しましたが、このfromPromiseAsyncを使って、vue-routerのルーティングを処理する関数を作ってみます。

vue-routerを使ったルーティング自体は直感的に扱えますが、例外が発生した時、どの例外をthrowするか?など例外処理を書いていく必要があります。

これらは設計時に決められると思いますが、利用される画面ごとで共通的な振る舞いをするケースも多く、バラバラに書かれていると後から変更するのも厳しくなるので、ラッパー関数を提供したいと思ったためです。

全体像

  • 型定義やtsdocsのせいで、長く見えますが肝心の関数自体でやっていることはとてもシンプルです。
import type { Result } from '../types'
import type { NavigationFailure, RouteLocationRaw } from 'vue-router'

import router from '@/router'
import { fromPromiseAsync } from '../fromPromiseAsync'

type ReturnRouterValues = Promise<void | NavigationFailure | undefined>
type RouterBind = (to: RouteLocationRaw) => Promise<Result<ReturnRouterValues, unknown>>


/** routerMethodsのwrapper関数 routerが初期化された後に利用する必要があるため関数でラップ  */
export const curryRouterPush: () => RouterBind = () => fromPromiseAsync(router.push.bind(router))
export const curryRouterReplace: () => RouterBind = () => fromPromiseAsync(router.replace.bind(router))

type SafeRouterBind = ({
  routerBind,
  toPath
}: {
  routerBind?: RouterBind
  toPath: string
}) => Promise<Result<ReturnRouterValues, unknown>>

/**
 * @description ルーティング処理をラップした高階関数、処理結果をResult型で返す
 * @param {RouterBind} routerBind - router.pushやreplaceの関数をラップした高階関数, デフォルトはrouter.push(curryRouterPush)
 * @param {string} toPath - 移動先のパス
 * @example 
 * 使用例:
 * const toAboutPage = async() => {
 *  const results = await safeRouterBind({ routerBind: curryRouterReplace(), toPath: "/about" })
 * } 
 */
export const safeRouterBind: SafeRouterBind = async({
  routerBind = curryRouterPush(),
  toPath
}) => {

  const resolveRouter = router.resolve(toPath)

  if (resolveRouter.name === undefined) {
    return { tag: 'failure', errors: new Error('The route name is not defined')}
  }

  const results = await routerBind(toPath)

  if (results.tag === 'failure' && results.errors instanceof Error && results.errors.name === 'NavigationFailure') {
    return { tag: 'failure', errors: new Error('NavigationFailure')}
  }

  
  return results
}

コンポーネント側での使用方法

const toAboutPage = async() => {
  const results = await safeRouterBind({ toPath: "/about" })
}

説明

まず、vue-routerrouter.pushrouter.replaceのメソッドを上記で作成した fromPromiseAsyncでラップした関数を作っています。
(vue-routerでは、routerが初期化された後に利用する制約があるためです)

/** routerMethodsのwrapper関数 routerが初期化された後に利用する必要があるため関数でラップ  */
export const curryRouterPush: () => RouterBind = () => fromPromiseAsync(router.push.bind(router))
export const curryRouterReplace: () => RouterBind = () => fromPromiseAsync(router.replace.bind(router))

次に関数の型定義です。

type SafeRouterBind = ({
  routerBind,
  toPath
}: {
  routerBind?: RouterBind
  toPath: string
}) => Promise<Result<ReturnRouterValues, unknown>>

オブジェクトの形で routerBind (上記で作成したrouterMethodsのwrapper関数)と toPath(遷移先のパス)を引数で受け取れるようにしています。

routerBind をオプショナルにしている理由しては、この後に登場しますが、router.push をデフォルト引数に設定しているためです。
replace は、ログイン後の遷移処理などで、遷移前の画面にブラウザバックさせないために使うなどの特定のケースで使用するという設計方針がある仮定で使いたい時だけ引数に入れるようにしました。

const results = await safeRouterBind({ routerBind: curryRouterReplace(),  toPath: "/about" })

最後に関数の実装部分です。

router.resolve で引数から受け取ったパスの存在チェックを行い、ルート名が存在しない場合など明示的にしたいエラーをthrowしています。

export const safeRouterBind: SafeRouterBind = async({
  routerBind = curryRouterPush(),
  toPath
}) => {

  const resolveRouter = router.resolve(toPath)

  if (resolveRouter.name === undefined) {
    return { tag: 'failure', errors: new Error('The route name is not defined')}
  }

  const results = await routerBind(toPath)

  if (results.tag === 'failure' && results.errors instanceof Error && results.errors.name === 'NavigationFailure') {
    return { tag: 'failure', errors: new Error('NavigationFailure')}
  }

  
  return results
}

この関数ではあくまでも、Result<T, E>のみを返しています。
実際に、この関数の戻り値を使って、画面表示を行う(トースト表示など)処理は、別関数で分けておくと良いと思います。

Higher-Order Function(高階関数)の注意点

高階関数の活用には注意点があります。必要になった時に、検討するようにすることを心がけた方が良さそうです。

  • Higher-Order Function(高階関数)は、再利用性や処理の一貫性を保つ上で有効なアプローチですが、高階関数を何層もラップするなどの実装をしてしまうと、逆に複雑になるので高階関数のネストやラップを適度に保つようにする必要がありそうです。
  • 例えば、ループ内で高階関数を頻繁に使用すると、新しい関数の生成やガベージコレクションによりパフォーマンスが低下する可能性があります。

まとめ

  • いい感じだ!と思ってもその分、型がどんどん複雑になっていくので結局のところTypeScript難しい...という感想です。

Discussion