🥶

TypescriptでOptionを作ってみた

に公開

この間Resultを作ったぞ〜という記事を書きましたが、今度はOptionを作ってみたぞ〜という記事を書きます。

https://zenn.dev/terusi/articles/064f506d8eb18a

Optionは軽くRustを触った時から「あ〜いいな〜」と思って使っているものです。
それ以降、自分でOptionを作って使っていました。

今回、自分だけのテンプレートcliを作っている際に機能等がまとまってきたので記事にします。

そもそもOptionとは

私はOptionをRustをやっているときに知りました。

https://doc.rust-jp.rs/book-ja/ch06-01-defining-an-enum.html#option-enumとnull値に勝る利点

https://doc.rust-jp.rs/rust-by-example-ja/std/option.html

これらの話は結構どこかの記事で話していたりするので多くは語りませんが、nullよりハンドリングしやすくていいよねといったものです。

詳しくはドキュメントを読んでください。

これをTypescriptでどう表現するのかを作りました。
これと同時に、関連する関数も作ってみました。

詳細

型定義

Optionの型定義をここでは示します。

const basic = {
    OPTION_SOME: "some",
    OPTION_NONE: "none"
} as const;

interface Some<T> {
    readonly kind: typeof basic.OPTION_SOME;
    readonly value: T;
}

interface None {
    readonly kind: typeof basic.OPTION_NONE;
}

export type Option<T> = Some<NonNullable<T>> | None;

この型定義ではnullundefinedは禁止としています。
というかnullundefinedを避けるために作っているので禁止しないことには「ダメでしょ!」となるかと思います。

Option生成関数

Optionを作成するcreateSomecreateNoneを作ります。

const createSome = <T>(value: NonNullable<T>): Option<T> => {
    return Object.freeze({
        kind: OPTION_SOME,
        value
    });
};

const createNone = (): Option<never> => {
    return Object.freeze({
        kind: OPTION_NONE
    });
};

なんか、説明することはないな。。。

somenoneかの判定関数

const isSome = <T extends NonNullable<unknown>>(
    opt: Option<T>
): opt is Some<T> => {
    return opt.kind === OPTION_SOME;
};

const isNone = <T>(opt: Option<T>): opt is None => {
    return opt.kind === OPTION_NONE;
};

somenoneかを判別する関数です。
これもResultと同じ作りです。(なんか、説明することはないな。。。)

optionに変換する関数(重要)

optionに変換する関数を用意しておきます。

const optionConversion = <T extends NonNullable<unknown>>(
    value: T | null | undefined
): Option<T> => {
    if (isNull(value) || isUndefined(value)) {
        return createNone();
    }

    return createSome(value);
};

nullundefinedOptionに書き換えている関数です。

この関数は重要だなと最近思っていててその出来事を話します。

昔、案件で実際にあった出来事

案件でreact-hook-formを使っていました。このライブラリを使ったことがある人だったらわかると思いますがwatchという関数があると思います。

https://react-hook-form.com/docs/usewatch/watch

この関数を使って指定したフォームの値をほじる処理を書いていました。(どういう処理だったっけ😅)

どういう処理だったか忘れましたが、nullが返ってきていて期待通りの動作でなかったというバグがあったことだけは鮮明に覚えています。(結構頑張ってコンソールデバッグをした記憶があります)

watch("hogehoge") //null or なんかフォームに入れた値

watchの仕様をよく理解していなかった自分自身にも非はありますが、人はミスを起こすものだと思うので(何立ち直ってるんだ😡)という話ですがこれを防げる関数が上の関数かなと思っています。

何が嬉しいのか

例えばですが、

optionConversion(watch("hogehoge"))

とすると必ずoptionで返ってくるのであ、ハンドリングしなきゃという気持ちになります。

外部で用いているライブラリで何かにアクセスしてその返却物を検査するときにかなり有効だと考えました。

即時関数でラップする

最終的には上記関数等を即時関数でくくります。

export const optionUtility = (function () {
    const { OPTION_SOME, OPTION_NONE } = basic;

    //今まで定義した関数等
    
    return Object.freeze({
        createSome,
        createNone,
        isSome,
        isNone,
        optionConversion
    });
})();

全体像

import { isNull, isUndefined } from "./is";

const basic = {
    OPTION_SOME: "some",
    OPTION_NONE: "none"
} as const;

interface Some<T> {
    readonly kind: typeof basic.OPTION_SOME;
    readonly value: T;
}

interface None {
    readonly kind: typeof basic.OPTION_NONE;
}

export type Option<T> = Some<NonNullable<T>> | None;

export const optionUtility = (function () {
    const { OPTION_SOME, OPTION_NONE } = basic;

    const createSome = <T>(value: NonNullable<T>): Option<T> => {
        return Object.freeze({
            kind: OPTION_SOME,
            value
        });
    };

    const createNone = (): Option<never> => {
        return Object.freeze({
            kind: OPTION_NONE
        });
    };

    const optionConversion = <T extends NonNullable<unknown>>(
        value: T | null | undefined
    ): Option<T> => {
        if (isNull(value) || isUndefined(value)) {
            return createNone();
        }

        return createSome(value);
    };

    const isSome = <T extends NonNullable<unknown>>(
        opt: Option<T>
    ): opt is Some<T> => {
        return opt.kind === OPTION_SOME;
    };

    const isNone = <T>(opt: Option<T>): opt is None => {
        return opt.kind === OPTION_NONE;
    };

    return Object.freeze({
        createSome,
        createNone,
        isSome,
        isNone,
        optionConversion
    });
})();

使用例

では使用例を載せておきます。
環境変数の取り扱いに関するソースコードを記載します。

import { Option, optionUtility } from "./option";

export function envParse(env: string | undefined): Option<string> {
    const { optionConversion } = optionUtility;

    return optionConversion<string>(env);
}

次に、環境変数のスキームを用意しておき、環境変数をそのまま使うのではなく必ず上の関数を経由します。

import { envParse } from "@/utils/env-parse";

export const appConfig = {
    apiKey: envParse(process.env.NEXT_PUBLIC_API_KEY),
    apiKey2: envParse(process.env.NEXT_PUBLIC_API_KEY2)
};

こうすることで、URLを取り出す際はvalueに入っているかどうかを確認しなくてはいけなくなります。

const createError = httpError.createHttpError;
const { isNone, createNone, createSome } = optionUtility;

const url:Option<string> = appConfig.apiKey;

if (isNone(url)) {
    return createNg(createError.notFoundAPIUrl());
}

const res = await checkPromiseReturn({
    fn: () => fetch(url.value, { cache }),
    err: createError.fetchError()
});

仮にURLがundefinedだった場合、

  • .envに定義していないか
  • 定義されている変数がおかしいのか

に絞られてきます。

そうなってくるとバグが起こっていたとしても原因を掴むのは速くなります。

環境変数に関してはundefinedだった場合を考慮してハンドリングできれば良いですが、忘れる時があるでしょう。

例えば次のような処理は絶対やってはあかんやつです。(昔やってた)

fetch(process.env.API_URL as string);

型アサーションでstringと決めつけてはいるものの.envが存在してない人もいるよね?っていうのでさらに最悪なパターンとしてtry/catchで囲ってないとプログラムはクラッシュします。
try/catchで囲っていててもどこでエラーが起こっているのかの原因特定は遅くなるんじゃないかなと思います。

最後に

OptionはTypescriptやJavascriptにはないのでわざわざ作る必要もないとは思いますが、案件が大きくなってきたりミスを連発する人なら実装しておいた方が良いのかなと感じています。コアの部分でnullundefinedか正規の値かの関数を作っておくことで、後々ハンドリングしなくてはいけないという意識、予期せぬnullundefinedを防げるようになると感じています。

Discussion