🍣

Result自作してみた😳

に公開

みなさんはResult自作していますか?

それともneverthrowを使っていますか?

https://github.com/supermacro/neverthrow

私はneverthrowを使ったことがないのですが、Resultは毎回自作しています。
今回、どのように作っているのかをお見せしようと思います。

型定義やv8エンジンを壊さないようにする書き方など参考になれば嬉しいです。

詳細

では実際に詳細を見てきましょう。

型定義(Result部分)

心臓部分であるResultの型定義です。

const basic = {
    RESULT_OK: "ok",
    RESULT_NG: "ng"
} as const;

interface OK<T> {
    readonly kind: typeof basic.RESULT_OK;
    readonly value: T;
}

interface NG<E> {
    readonly kind: typeof basic.RESULT_NG;
    readonly err: E;
}

export type Result<T, E> = OK<NonNullable<T>> | NG<NonNullable<E>>;

上記のような型定義になりましたが、皆さんならどう型定義をするでしょうか??

ポイントを列挙しておきます。

  • interfaceを基本としている
    • interfaceの方が軽いというのはドキュメントで読んだことがあるため
    • 型の表現は全く考慮していなかった
  • NonNullableを使っている
    • nullundefinedを入れると結構ややこしくなる
    • 少なくとも私は上記を扱うのが下手なので使わないようにしている
    • それに外部から飛んできた時にバグ散らかすことが多いので
    • nullだったりを別に入れてもハンドリング完璧にできるぜ!」というプログラマーさんなら別に入れなくても良いでしょう

Resultを作成する関数

それぞれOKNGを作成する関数を作成しています。

const createOk = <T>(value: NonNullable<T>): Result<T, never> => {
    return Object.freeze({
        kind: RESULT_OK,
        value
    });
};

const createNg = <E>(err: NonNullable<E>): Result<never, E> => {
    return Object.freeze({
        kind: RESULT_NG,
        err
    });
};

ポイントを列挙しておきます。

  • この関数を作っておくことで関数を呼ぶだけでオブジェクトが生成される
    • 毎回手でオブジェクト作るの嫌だよね〜〜
  • Object.freezeを使ってオブジェクトを書き換えできないようにしている

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze

NGかどうか、OKかどうかの関数

NGOKかを判定する関数を作成しています。

const isOK = <T extends NonNullable<unknown>, E>(
    res: Result<T, E>
): res is OK<T> => {
    return res.kind === RESULT_OK;
};

const isNG = <T, E extends NonNullable<unknown>>(
    res: Result<T, E>
): res is NG<E> => {
    return res.kind === RESULT_NG;
};

ポイントは特にないですが、これをしておくことで

if (result.kind === "ng")

といったことを書かずに

if (isNg(result))

とすることで簡単なミスも防げるようになるでしょう。

検証系の関数

ここでは、try/catchでのchecker関数を作成しています。

interface CheckResultReturn<T, E> {
    fn: () => NonNullable<T>;
    err: NonNullable<E>;
}

interface CheckResultVoid<E> {
    fn: () => void;
    err: NonNullable<E>;
}

interface CheckPromiseReturn<T, E> {
    fn: () => Promise<NonNullable<T>>;
    err: NonNullable<E>;
}

interface CheckPromiseVoid<E> {
    fn: () => Promise<void>;
    err: NonNullable<E>;
}

const UNIT_SYMBOL = Symbol("UNIT_SYMBOL");

interface Unit {
    readonly _unit: typeof UNIT_SYMBOL;
}

const UNIT: Unit = Object.freeze({
    _unit: UNIT_SYMBOL
});

const checkPromiseReturn = async <T, E>({
    fn,
    err
}: CheckPromiseReturn<T, E>): Promise<Result<T, E>> => {
    try {
        const result = await fn();

        return createOk(result);
    } catch (error) {
        return createNg(err);
    }
};

const checkPromiseVoid = async <E>({
    fn,
    err
}: CheckPromiseVoid<E>): Promise<Result<Unit, E>> => {
    try {
        await fn();

        return createOk(UNIT);
    } catch (error) {
        return createNg(err);
    }
};

const checkResultReturn = <T, E>({
    fn,
    err
}: CheckResultReturn<T, E>): Result<T, E> => {
    try {
        const result = fn();

        return createOk(result);
    } catch (error) {
        return createNg(err);
    }
};

const checkResultVoid = <E>({
    fn,
    err
}: CheckResultVoid<E>): Result<Unit, E> => {
    try {
        fn();

        return createOk(UNIT);
    } catch (error) {
        return createNg(err);
    }
};

ポイントは以下です。

  • どの関数も似た関数だがv8エンジンの最適化を意識している
    • 戻り値の型が違うと最適化が解除されてしまうと最近学んだ
  • void関数の場合はRustでいうOK(())と同じ表現を使っている

全体の関数

これらを囲っている全体の関数は以下のように宣言しました。

export const resultUtility = (function () {
    const { RESULT_NG, RESULT_OK } = basic;

    //今までの関数処理

    return Object.freeze({
        UNIT,
        checkResultReturn,
        checkResultVoid,
        checkPromiseReturn,
        checkPromiseVoid,
        isOK,
        isNG,
        createOk,
        createNg
    });
})();

今回のケースでは即時関数が適しているので即時関数を採用しています。

全体像

全体像を以下に示します。

const basic = {
    RESULT_OK: "ok",
    RESULT_NG: "ng"
} as const;

interface OK<T> {
    readonly kind: typeof basic.RESULT_OK;
    readonly value: T;
}

interface NG<E> {
    readonly kind: typeof basic.RESULT_NG;
    readonly err: E;
}

interface CheckResultReturn<T, E> {
    fn: () => NonNullable<T>;
    err: NonNullable<E>;
}

interface CheckResultVoid<E> {
    fn: () => void;
    err: NonNullable<E>;
}

interface CheckPromiseReturn<T, E> {
    fn: () => Promise<NonNullable<T>>;
    err: NonNullable<E>;
}

interface CheckPromiseVoid<E> {
    fn: () => Promise<void>;
    err: NonNullable<E>;
}

const UNIT_SYMBOL = Symbol("UNIT_SYMBOL");

interface Unit {
    readonly _unit: typeof UNIT_SYMBOL;
}

export type Result<T, E> = OK<NonNullable<T>> | NG<NonNullable<E>>;

export const resultUtility = (function () {
    const { RESULT_NG, RESULT_OK } = basic;

    const UNIT: Unit = Object.freeze({
        _unit: UNIT_SYMBOL
    });

    const checkPromiseReturn = async <T, E>({
        fn,
        err
    }: CheckPromiseReturn<T, E>): Promise<Result<T, E>> => {
        try {
            const result = await fn();

            return createOk(result);
        } catch () {
            return createNg(err);
        }
    };

    const checkPromiseVoid = async <E>({
        fn,
        err
    }: CheckPromiseVoid<E>): Promise<Result<Unit, E>> => {
        try {
            await fn();

            return createOk(UNIT);
        } catch () {
            return createNg(err);
        }
    };

    const checkResultReturn = <T, E>({
        fn,
        err
    }: CheckResultReturn<T, E>): Result<T, E> => {
        try {
            const result = fn();

            return createOk(result);
        } catch (error) {
            return createNg(err);
        }
    };

    const checkResultVoid = <E>({
        fn,
        err
    }: CheckResultVoid<E>): Result<Unit, E> => {
        try {
            fn();

            return createOk(UNIT);
        } catch (error) {
            return createNg(err);
        }
    };

    const isOK = <T extends NonNullable<unknown>, E>(
        res: Result<T, E>
    ): res is OK<T> => {
        return res.kind === RESULT_OK;
    };

    const isNG = <T, E extends NonNullable<unknown>>(
        res: Result<T, E>
    ): res is NG<E> => {
        return res.kind === RESULT_NG;
    };

    const createOk = <T>(value: NonNullable<T>): Result<T, never> => {
        return Object.freeze({
            kind: RESULT_OK,
            value
        });
    };

    const createNg = <E>(err: NonNullable<E>): Result<never, E> => {
        return Object.freeze({
            kind: RESULT_NG,
            err
        });
    };

    return Object.freeze({
        UNIT,
        checkResultReturn,
        checkResultVoid,
        checkPromiseReturn,
        checkPromiseVoid,
        isOK,
        isNG,
        createOk,
        createNg
    });
})();

実際に使った一例

例としてfetcher関数をあげておきます。
Optionとかとか作成していますが、今回は説明を割愛します。

import { core, ZodType } from "zod";
import { Option, optionUtility } from "@/utils/option";
import { Result, resultUtility } from "@/utils/result";
import httpError, { HttpError } from "@/utils/error/http";
import error from "@/utils/error/http";

export async function fetcher<T extends ZodType>({
    url,
    scheme,
    cache
}: {
    url: Option<string>;
    scheme: T;
    cache?: RequestCache;
}): Promise<Result<core.output<T>, HttpError>> {
    const httpErrorScheme = error.createHttpScheme;
    const createError = httpError.createHttpError;

    const { isNone, createNone } = optionUtility;
    //1.
    const { isNG, createNg, createOk, checkPromiseReturn } = resultUtility;

    if (isNone(url)) {
        return createNg(createError.notFoundAPIUrl());
    }
    //2.
    const res = await checkPromiseReturn({
        fn: () => fetch(url.value, { cache }),
        err: createError.fetchError()
    });

    //3.
    if (isNG(res)) {
        return res;
    }

    if (!res.value.ok) {
        const status = res.value.status;
        
        switch (status) {
            case httpErrorScheme.httpErrorStatusResponse.notFound:
                //4. 
                return createNg(createError.returnNotFoundAPIUrl());
            case httpErrorScheme.httpErrorStatusResponse.forbidden:
                return createNg(createError.returnNoPermission());
            case httpErrorScheme.httpErrorStatusResponse.badRequest:
                return createNg(createError.returnBadRequest());
            case httpErrorScheme.httpErrorStatusResponse.internalServerError:
                return createNg(createError.returnInternalServerError());
            default:
                return createNg(createError.unknownError());
        }
    }

    const resValue = await res.value.json();

    const judgeType = scheme.safeParse(resValue);

    if (isUndefined(judgeType.error)) {
        //5. 
        return createNg(createError.schemeError());
    }

    const okValue = judgeType.data;

    if (okValue === undefined || okValue === null) {
        //6. 
        return createOk(createNone());
    }

    //7. 
    return createOk(createSome(okValue));
}
  1. resultの即時関数を呼び出す
  2. fetchの成功か失敗かを返す
    1. 失敗かどうかの判定
  3. httpエラーステータスに応じてerrorオブジェクトを返す
  4. スキームが間違っていた場合にはerrorオブジェクトを返す
  5. okValueにはundefinednullの可能性があるため検証しOKとしてNoneを返す
  6. okValueに 6. 以外の値が入っていたらOKとして返却する

ちょっと例が長いソースコードにはなってしまいましたが、fetcherはよく作ると思うので良いかなと思っています。

まとめ

今回自身で作ったResultを紹介しました。

意識したことは型を安全に、保守をしやすく、v8に優しくです。
意識しただけであって、そうなってない部分はあるとは思いますが今後も育てていこうと思います。
Resultを使おうと思った際には自身で作成するなりneverthrowを作成するなりしてみてください。
自作する際、少しは参考になれば幸いです。

Discussion