😃

TypeScriptで学ぶ古典的ADT - Maybe

2021/08/29に公開

本記事はこの記事の続きです。
https://zenn.dev/eagle/articles/ts-coproduct-introduction
実用性の観点から割愛したMaybeを改めて紹介します。

代数的データ型をコードで表現するために次のコード群を使用します。

type Coproduct<T> = {
  [K in keyof T]: Record<"type", K> & T[K];
}[keyof T];

type Individual<TCoproduct extends Record<"type", keyof any>, Tag> = Extract<
  TCoproduct,
  Record<"type", Tag>
>;

type Match<TCoproduct extends Record<"type", keyof any>, Tag> = Omit<
  Individual<TCoproduct, Tag>,
  "type"
>;

function match<TCoproduct extends Record<"type", keyof any>, TOut>(
  value: TCoproduct,
  patterns: {
    [K in TCoproduct["type"]]: (param: Match<TCoproduct, K>) => TOut;
  },
): TOut {
  const tag: TCoproduct["type"] = value.type;
  return patterns[tag](value as any);
}
  • Coproduct<T>は代数的データ型の「または」の型を表すためのヘルパーです。実装はtypeというタグをつけたオブジェクトの型のユニオン[1]です。
  • Individual<TCoproduct, Tag>は「または」の型の個別の型を表すためのヘルパーです。
  • match(value, patterns)は「または」の型に対するパターンマッチングを行うためのヘルパーです。

Maybeの無い世界

Maybe<T>は「型Tの値がある」または「値が何らかの理由で無い」ことを表す値の型です。

例えば、Array.prototype.indexOfを見てみます。
このメソッドは指定された要素が配列の中にある場合はそのインデックスを返しますが、見つからなければ-1を返します。

const arr = ["apple", "banana", "cherry"];
const indexOfBanana: number = arr.indexOf("banana"); // 1(0から数えて1番目)
const indexOfZucchini: number = arr.indexOf("zucchini"); // -1(存在しない)

さて、ここでメソッドの返り値を受けた変数indexOfBanana: numberに注目しましょう。
この値が-1かどうかをif文などで条件分岐するのはプログラムを書く人の責務です。

const arrHasBanana: boolean = indexOfBanana >= 0;
if (arrHasBanana) {
  console.log(`${indexOfBanana + 1}番目にbananaがあります。`);
} else {
  console.log(`bananaがありませんでした。`);
}

もう1つ例を挙げましょう。
parseInt関数は文字列を数値に変換しますが、できなければNaNを返します。

// 第2引数の 10 は十進法を表す
const parse42: number = parseInt("42", 10); // 42
const parseabc: number = parseInt("abc", 10); // NaN

パーズに成功したかどうかをif文などで条件分岐するのはプログラムを書く人の責務です。
NaN: numberです。

// parseSomething: number(※NaNの場合もあり得る)

if (!Number.isNaN(parseSomething)) {
  // 成功
} else {
  // 失敗
}

このように、プログラムを書く人が暗黙の裡にやらなければならないことが発生します。
これらの処理に漏れがあるとそれはバグを引き起こす要因となります。

Maybe<T>の定義

そこで、Maybe<T>を定義します。

type Maybe<T> = Coproduct<{
  Just: { value: T };
  Nothing: {};
}>;

これは次のコードと同じです。
(詳細は前回の記事を参照してください。)

type Maybe<T> =
  | { type: "Just"; value: T }
  | { type: "Nothing" };

つまり、Maybe<T>型の値は、JustというタグかNothingというタグがついており、
前者の場合は更にT型の値をvalueに持ちます。

Maybe<T>型の値を作成する方法は、単にそのようなオブジェクトを作成すればOKです。

const just42 = { type: "Just", value: 42 } as Maybe<number>;

これを行うためにヘルパー関数を用意しましょう。
※本記事では敢えてCamelCaseで命名します。

function Just<T>(value: T): Maybe<T> {
  return { type: "Just", value };
}
const Nothing: Maybe<never> = { type: "Nothing" };

次のように使うことができます。

const just42 = Just(42); // { type: "Just", value: 42 }

Maybe<T>を返す関数

さて、先ほどのArray.prototype.indexOfをラップしてMaybe<number>を返す関数を作成してみます。
配列の中に所望の要素があれば成功と見なし、Justというタグを付けてその要素のインデックスを返します。
要素が無ければ失敗と見なし、Nothingというタグを付けた空のオブジェクトを返します。

function indexOfArray<T>(item: T, arr: T[]): Maybe<number> {
  const index = arr.indexOf(item);
  if (index >= 0) {
    return Just(index);
  } else {
    return Nothing;
  }
}

先ほどの例でこの関数を使ってみましょう。

const arr = ["apple", "banana", "cherry"];
const maybeIndexOfBanana = indexOfArray("banana", arr); // Just(1)
const maybeIndexOfZucchini = indexOfArray("zucchini", arr); // Nothing

さて、ここで関数の返り値maybeIndexOfBanana: Maybe<number>に注目しましょう。
この型はMaybe<number>なので、number型の値がある場合とない場合があり得ることが分かります。

次に、作成したMaybe<T>型の値を使ってみましょう。
「または」の型を使う際は、冒頭で定義したヘルパー関数matchを使います。

match(maybeIndexOfBanana, {
  Just: ({ value }) => {
    // Justのとき、すなわち要素があったとき
    // valueにインデックスが格納されている
    console.log(`${value + 1}番目にbananaがあります。`);
  },
  Nothing: (_) => {
    // Nothingのとき、すなわち要素が無かったとき
    console.log(`bananaがありませんでした。`);
  },
});

maybeIndexOfBananaから直接valueを取り出すことはできません。
これは、Nothingである場合があり得るからです。
したがって、インデックスを知るには必ずmatchなどを用いて場合分けを行う必要があります。

matchを使う恩恵として、場合分けに漏れがあるとコンパイルエラーとなります。
例えば、配列の中に要素が無かった場合の処理をうっかり忘れてしまったとします。

match(maybeIndexOfBanana, {
  Just: ({ value }) => {
    console.log(`${value + 1}番目にBananaがあります。`);
  },
});

これはコンパイルエラーとなります。

場合分け漏れによるコンパイルエラーの図
場合分け漏れによるコンパイルエラー

赤枠で囲んだところに漏れている場合が表示されます。

したがって、次のコードのように自力で場合分けをするような処理はアンチパターンです。

// 👎 NG
if (maybeIndexOfBanana.type === "Just") {
  const indexOfBanana = maybeIndexOfBanana.value
  // ...
}
// maybeIndexOfBanana.type === "Nothing"の場合の
// 処理漏れを引き起こす可能性があるためNG
// matchを使うのが良い

これでは敢えてMaybe<T>型を導入する利点がありません。
if文による場合分けは避け、代わりにmatchを使うようにしましょう。

ここまでの話を踏まえて、Maybe<T>を導入する意義をまとめます。
Maybe<T>を使うと、その中の値value: Tにアクセスするために場合分けが強制されます。
その場合分けをmatchを使って行うことで場合分けの処理漏れを防ぐことができます。

(練習問題)
Array.prototype.indexOfのときと同様に、parseIntをラップしてMaybe<number>を返す関数safeParseIntを作成してください。基数は10とします。

declare function safeParseInt(str: string): Maybe<number>;
解答例`safeParseInt`
function safeParseInt(str: string): Maybe<number> {
  const result = parseInt(str, 10);
  if (!Number.isNaN(result)) {
    return Just(result);
  } else {
    return Nothing;
  }
}

型安全性の追及

さて、safeParseIntを使うとパーズ結果としてnumberではなくMaybe<number>を返すため、
パーズに成功したか失敗したかという場合分けをする際にmatchを使うことで
漏れなく処理できるということが分かりました。

しかし、Maybe<number>の中身は依然としてnumber型であるので、
型だけ見るとNaNが含まれるかもしれない」という情報が含まれています。
実際にはNaNにならないように条件分岐しているので、これは不満です。

NaN値を取り得ないことをどうしても強調したい場合、
新しい型を定義することで実現できます。

/**
 * (NaNやInfinityでない)有限な数値を表す型(のつもり)
 */
type FiniteNumber = number & { FiniteNumber: never }

FiniteNumber型はFiniteNumberというタグを付けたnumberです。
したがって、TypeScriptコンパイラはFiniteNumberであればnumberでもあるということを認識できます。
また、FiniteNumberであるためには少なくともnumberでなければならないということも認識できます。

const bool = true as FiniteNumber; // numberでないのでコンパイルエラー
const str = "42" as FiniteNumber; // 同じくコンパイルエラー
const num = 42 as FiniteNumber; // これはOK

const fourtySeven = num + 5; // FiniteNumberはnumberでもあるのでOK

しかし、FiniteNumber型を「(NaNやInfinityでない)有限な数値を表す型にしたい」という
人間の気持ちまでは汲み取ってくれないため、FiniteNumber型をそのような型に保つのは
プログラムを書く人の責任[2]となります。

const fourtyTwo = 42 as FiniteNumber; // これはOK
const evilNumber = Infinity as FiniteNumber; // 本来NGだがコンパイルできてしまう😈

つまり、FiniteNumberへのキャストは細心の注意を払う必要があります。
今回はnumber型の値が有限かどうかをNumber.isFinite関数で判定し、
有限であればJust、そうでなければNothingのタグを付けて返す関数を作成します。

function safeFinite(n: number): Maybe<FiniteNumber> {
  if (Number.isFinite(n)) {
    // nは有限なのでキャストしても良い
    return Just(n as FiniteNumber);
  } else {
    return Nothing;
  }
}

試しにFiniteNumberの絶対値を取得する関数を書いてみましょう。
引数をFiniteNumberに限定することにより、返り値の範囲も狭めることができます。

function finiteAbs(n: FiniteNumber): FiniteNumber {
  // Math.abs(NaN)はNaNを返し、Math.abs(Infinity)はInifinityを返すので
  // 本来Math.absの返り値の型としてはnumberが相応しいです
  //
  // しかし、NaNやInfinityはFiniteNumber型には含まれない(ことになっている)ので
  // Math.absの引数にNaNやInfinityが入ることはありません
  //
  // 以上により、FiniteNumber値の絶対値をFiniteNumberにキャストしても良い
  return Math.abs(n) as FiniteNumber;
}

それでは使ってみましょう。

finiteAbs(NaN); // コンパイルエラー
finiteAbs(-42) // 残念ながらこれもコンパイルエラー

const maybeFinite = safeFinite(-42);
match(maybeFinite, {
  Just: ({ value }) => {
    // safeFiniteを通過した値のみ引数に使うことができる
    console.log(finiteAbs(value)); // 42
  },
  Nothing: (_) => {
    console.log("数値が正しくありません。");
  },
});

このように、finiteAbs(-42)もコンパイルエラーとして弾かれてしまう[3]のが玉に瑕ですが、
その引き換えとして型に追加情報を含ませることができます。

文字列からFiniteNumber型へのパーズ関数parseFiniteIntparseFiniteFloatを書いてみます。

function parseFiniteInt(str: string): Maybe<FiniteNumber> {
  const result = parseInt(str, 10);
  return safeFinite(result); 
}

function parseFiniteFloat(str: string): Maybe<FiniteNumber> {
  const result = parseFloat(str);
  return safeFinite(result); 
}

ドメインロジックの記述

先ほどの例のようにわざわざFiniteNumberという型を定義して、
多少の不便を負ってでも型安全性を重視したいというのは稀でしょう。
しかし、ドメインロジックを潔癖に保っておくことはしばしば有用です。

例えば、年齢Ageを考えます。
年齢を表すために使う型は素朴に考えるとnumberですが、
number型はNaN-1003.14100058などの様々な値を許します。

このそれぞれにをつけてみましょう。
NaN歳-100歳3.14歳100058歳等々、異様です。

年齢が整数でなかったり、マイナスであったり、150を越える場合は失敗扱いにしたいとします。

number型の数値が今述べた基準で年齢として妥当かどうかを判定する関数を書いてみます。

function isValidAsAge(num: number): boolean {
  return 0 <= num && num <= 150;
}

典型的なコードは次のようにif文で分岐して、年齢として妥当な場合の処理と
年齢として妥当ではない場合の処理を記述することです。

// rawInput: number
// 例えばJSONから拾ってきた数値であったり、ユーザー入力から取得してきた数値

if (isValidAsAge(rawInput)) {
  // OK
  // 正常処理
} else {
  // 年齢として妥当ではない
  // 例外的な処理
}

ここで、場合分け漏れによりrawInputをそのまま使ってしまうと不具合の原因になります。

そこで、先ほどのトリックを使い、Age型を新たに定義します。

type Age = number & { Age: never };

そして、Age型の値を作成するための関数を定義します。

function createAge(num: number): Maybe<Age> {
  if (isValidAsAge(num)) {
    return Just(num as Age);
  } else {
    return Nothing;
  }
}

これにより、matchによるコーディングが可能になります。

// rawInput: number
// 例えばJSONから拾ってきた数値であったり、ユーザー入力から取得してきた数値

const maybeAge = createAge(rawInput);

match(maybeAge, {
  Just: ({ value }) => {
    // OK
    // 正常処理 value: Age
  },
  Nothing: (_) => {
    // 年齢として妥当ではない
    // 例外的な処理
  },
});

また、Ageを要求する箇所ではnumberと区別されるため、これにより堅牢性が増します。
例えばAge型の値を引数にとる関数を作成してみます。

function toStringAge(age: Age): string {
  return `あなたは${age}歳です。`;
}

createAgeにより検証を通過した値でなければこの関数の引数に使うことができないため、
裏を返せばtoStringAgeを使えば妥当な年齢のみ文字列化できることになります。

toStringAge(100000000); // コンパイルエラー

const maybeAge = createAge(100000000);
match(maybeAge, {
  Just: ({ value }) => {
    // value: Age
    // 検証を通った数値のはず
    console.log(toStringAge(value));
  },
  Nothing: (_) => {
    console.log(`処理に失敗しました。`);
  }
})

これにより、Age型を定義する前は「年齢を扱う際は必ずisValidAsAgeを使って検証する」という暗黙のルールがありましたが、
Age型を定義した後は「年齢を使う際はcreateAgeを使ってAge型の値を生成する」という暗黙のルールに変わりました。

前者の暗黙のルールはプログラムを書く人が忘れずに守るよう努力しなければなりませんが、
後者の暗黙のルールはTypeScriptコンパイラによる型チェック機能があるため、
プログラムを書く人が意図的にAgeanyへのキャストを行わない限りルールが破られることはありません。

Maybe<T>のヘルパー関数

Maybe<T>型の値を作成するためのJustNothingおよび、
Maybe<T>型の値を分解して使用するためのmatchMaybe<T>を扱う上での基本的パーツです。
これらだけでもMaybe<T>型を扱うことができますが、もっと便利に使えるようにヘルパー関数を作成していきます。

最終的にはmatchを直接使うことなしに[4]ほとんどのコードを書けるようになります。

型Tの値と述語からMaybe<T>の値を作成する

Maybe<T>を使う際は、undefinedNaNなどの特別な値をNothing扱いにしたい場合が多いです。
T型の値を既に持っていて、その値を何らかの条件でJustNothingかを振り分けてMaybe<T>にするというのは頻出パターンです。

T型の値がJustNothingかを表すようなコードは、Tからbooleanへの関数で表すことができます。
これは述語と呼ばれることもあります。

Justのとき、すなわち値があるときtrueを返し、Nothingのときにfalseを返すような述語からMaybe<T>を作成する関数fromPredicateを作成します。

function fromPredicate<T>(pred: (value: T) => boolean, value: T): Maybe<T> {
  if (pred(value)) {
    return Just(value);
  } else {
    return Nothing;
  }
}
predの返り値 fromPredicateの返り値のタグ
true "Just"
false "Nothing"

これを用いると、先ほど作成したindexOfArray関数は次のようにも実装できます。

const indexOfArray = <T>(item: T, arr: T[]): Maybe<number> =>
  fromPredicate((index) => index >= 0, arr.indexOf(item));

このように、fromPredicateを使うと、ヘルパー関数であるfromPredicateの中に
if文を閉じ込めることができます。(広義のカプセル化)

(練習問題)
関数safeParseIntを、fromPredicateヘルパーを用いて再実装してください。

declare function safeParseInt(str: string): Maybe<number>;
解答例`safeParseInt`
const safeParseInt = (str: string) =>
  fromPredicate((int) => !Number.isNaN(int), str);

Maybe<T>の値と述語からタグを付け直す

先ほどはT型の値からMaybe<T>型の値を作成しました。
では、既にMaybe<T>型の値を持っていた場合はどうでしょう?

例えば、maybeAge: Maybe<Age>を持っているとしましょう。

// rawInput: number
// 例えばJSONから拾ってきた数値であったり、ユーザー入力から取得してきた数値

const maybeAge: Maybe<Age> = createAge(rawInput);

年齢が60歳以上のときのみ正常処理を行い、
もともとの数値が年齢として正しくなかったり、年齢としては妥当であっても20歳未満であったりした場合には同じ処理を行うとします。

match(maybeAge, {
  Just: ({ value }) => {
    if (value >= 60) {
      // 正常処理
      console.log(`あなたは${value}歳です`);
    } else {
      console.log(`シルバー会員は60歳からです`); // 同じ処理
    }
  },
  Nothing: (_) => {
    console.log(`シルバー会員は60歳からです`); // 同じ処理
  },
});

ここで、Maybe<T>と述語からMaybe<T>を作成するヘルパー関数filterMaybeを作成してみます。

function filterMaybe<T>(
  pred: (value: T) => boolean,
  mvalue: Maybe<T>,
): Maybe<T> {
  return match(mvalue, {
    Just: ({ value }) => fromPredicate(pred, value),
    Nothing: (_) => Nothing,
  });
}

引数のMaybe<T>Justのときはその中身の値が述語predtrueにすることを確かめ、Justのままにします。
ただし、述語predを満たさない場合は値を捨ててNothingにします。
引数のMaybe<T>が元々NothingのときはそのままNothingにします。

mvalueのタグ predの返り値 filterMaybeの返り値のタグ
"Just" true "Just"
"Just" false "Nothing"
"Nothing" true "Nothing"
"Nothing" false "Nothing"

これは述語predを使ってMaybe<T>をふるいにかけているようなイメージです。
述語predの結果に応じてJustNothingのタグを付け直します。

ここでfromPredicatefilterMaybeの型を比べてみましょう。

fromPredicate: <T>(pred: (value: T) => boolean,  value:       T ) => Maybe<T>
filterMaybe  : <T>(pred: (value: T) => boolean, mvalue: Maybe<T>) => Maybe<T>

判定に使う値が生の値value: Tであるか、既にMaybe<_>で包まれた値mvalue: Maybe<T>であるかだけの違いであることが分かります。

さて、filterMaybe関数を使うと先ほどの処理は次のように実装できます。

const maybe60AndOver = filterMaybe((age) => age >= 60, maybeAge);
match(maybe60AndOver, {
  Just: ({ value }) => {
    // 正常処理
    console.log(`あなたは${value}歳です`);
  },
  Nothing: (_) => {
    // その他の処理
    console.log(`シルバー会員は60歳からです`);
  },
});

例外的な処理の記述が1箇所にまとまりました。
このように、なるべくmatchを直接使うタイミングを遅らせることでコードが簡潔になります。

Maybe<T>の値とデフォルト値からTの値を得る

null合体演算子??と同等のことをMaybe<T>でもできるようにしましょう。

function greeting(name?: string | null): string {
  return `${name ?? "名無し"}さんこんにちは`;
}

そこで、defaultValue関数を定義します。

function defaultValue<T>(value: T, mt: Maybe<T>): T {
  return match(mt, {
    Just: ({ value }) => value,
    Nothing: (_) => value,
  });
}
function greeting(name: Maybe<string>): string {
  return `${defaultValue("名無し", name)}さんこんにちは`;
}

あるいは必要なときだけ規定値が計算されるようにするために、
関数を引数として受け取るバージョンもあると良いかもしれません。

function defaultWith<T>(thunk: () => T, mt: Maybe<T>): T {
  return match(mt, {
    Just: ({ value }) => value,
    Nothing: (_) => thunk(),
  });
}

Maybe<T>の値をMaybe<U>の値に変換する

年齢でChildRegularSilverの3階級に分けるとします。

  • 20歳未満はChildとする
  • 60歳以上はSilverとする
  • それ以外はRegularとする
type AgeKind = "Child" | "Regular" | "Silver"

AgeからAgeKindへの関数を書くことができます。

function toAgeKind(age: Age): AgeKind {
  if (age < 20) return "Child";
  if (age >= 60) return "Silver";
  return "Regular";
}

しかし、ここまでで何度か繰り返した通り、Ageを作るためには
createAgeを通らなければならないため、Ageを直接持っている場面はほとんどありません。
その代わりにMaybe<Age>値を持っていることがほとんどです。

そこで、引数の型Ageと返り値の型AgeKindの両方をMaybeで包んだ関数を考えます。
このようにそれぞれの型をMaybe等の同じ型で包むことを「リフトする」と呼ぶことがあります。

function toAgeKindLifted(age: Maybe<Age>): Maybe<AgeKind> {
  // ...
}

中身も実装してしまいます。
リフトされた関数toAgeKindLiftedは、リフト前の関数toAgeKindを使って実装できます。

function toAgeKindLifted(age: Maybe<Age>): Maybe<AgeKind> {
  return match(age, {
    Just: ({ value }) => Just(toAgeKind(value)),
    Nothing: (_) => Nothing,
  });
}

どのような関数でも使えるように、mapMaybeというヘルパー関数を定義します。

function mapMaybe<T, U>(f: (t: T) => U, mt: Maybe<T>): Maybe<U> {
  return match(mt, {
    Just: ({ value }) => Just(f(value)),
    Nothing: (_) => Nothing,
  });
}

これにより、toAgeKindLiftedは次のように実装できます。

const toAgeKindLifted = (age: Maybe<Age>) => mapMaybe(toAgeKind, age);

(練習問題)
mtのタグとmapMaybeの返り値のタグの関係を表にしてください。

(解答例)
mtのタグ mapMaybeの返り値のタグ
"Just" "Just"
"Nothing" "Nothing"

(練習問題)
filterMaybeの節で次の記述をしました。

const maybe60AndOver = filterMaybe((age) => age >= 60, maybeAge);
match(maybe60AndOver, {
  Just: ({ value }) => {
    // 正常処理
    console.log(`あなたは${value}歳です`);
  },
  Nothing: (_) => {
    // その他の処理
    console.log(`シルバー会員は60歳からです`);
  },
});

この実装を、これまで作ってきたヘルパー関数を駆使してmatchを直接使わない実装にリファクタリングしてください。

(解答例)
const maybe60AndOver = filterMaybe((age) => age >= 60, maybeAge);
const maybeStr = mapMaybe((age) => `あなたは${value}歳です`, maybe60AndOver);
const message = defaultValue(`シルバー会員は60歳からです`, maybeStr);
console.log(message);

matchを直接使うタイミングをなるべく遅らせることでコードが簡潔になります。
今回の例ではヘルパー関数が充実しているため、そもそもmatchが不要になりました。

複数のMaybe<_>値からMaybe<U>の値を作成する

mapMaybeでは1引数の関数をリフトすることができました。
では、2引数、3引数ではどうでしょうか?

例えば、文字列strの長さが指定の長さlenより長いときにtrueを返す関数isLongerを考えます。

function isLonger(str: string, len: number): boolean {
  return str.length > len;
}

既にMaybe<string>値とMaybe<number>値を持っており、
どちらかがNothingであればNothingを返し、
どちらもJustであればisLongerを適用した結果を返したいとします。

function isLongerLifted(
  mstr: Maybe<string>,
  mlen: Maybe<number>,
): Maybe<boolean> {
  //
}

実装してみましょう。実装の方法は色々あります。
例えば、mstrmlenを地道に展開してみます。

function isLongerLifted(
  mstr: Maybe<string>,
  mlen: Maybe<number>,
): Maybe<boolean> {
  return match(mstr, {
    Just: (mstr) =>
      match(mlen, {
        Just: (mlen) => Just(isLonger(mstr.value, mlen.value)),
        Nothing: (_) => Nothing,
      }),
    Nothing: (_) => Nothing,
  });
}

もう少し楽にできる方法を考えましょう。

まず、通常の関数適用を考えます。
T型の値に、TからUへの関数fを適用してU型の値を得ます。

function apply<T, U>(f: (t: T) => U, t: T): U {
  return f(t);
}

次に、これをリフトします。

function applyMaybe<T, U>(mf: Maybe<(t: T) => U>, mt: Maybe<T>): Maybe<U> {
  return match(mf, {
    Just: ({ value /* value: (t: T) => U */ }) => mapMaybe(value, mt),
    Nothing: (_) => Nothing,
  });
}

mf: Maybe<(t: T) => U>mt: Maybe<T>のどちらかがNothingの場合はNothingを返し、
どちらもJustの場合は関数適用の結果にJustタグを付けて返します。

mfのタグ mtのタグ applyMaybeの返り値のタグ
"Just" "Just" "Just"
"Just" "Nothing" "Nothing"
"Nothing" "Just" "Nothing"
"Nothing" "Nothing" "Nothing"

mapMaybeapplyMaybeを比べてみましょう。

mapMaybe  : ( f:       (t: T) => U , mt: Maybe<T>): Maybe<U>
applyMaybe: (mf: Maybe<(t: T) => U>, mt: Maybe<T>): Maybe<U>

関数がMaybe<_>で包まれているかどうかの違いだけであることが分かります。

これらを用いて、先ほどのisLongerLifted関数は次のように実装できます。

function isLongerLifted(
  mstr: Maybe<string>,
  mlen: Maybe<number>,
): Maybe<boolean> {
  // まず、関数`isLonger`の引数を1つずつ分解します
  const f = (str: string) => (len: number) => isLonger(str, len);

  // これにより1引数の関数になるので`mapMaybe`を使うことができます
  const mf: Maybe<(len: number) => boolean> = mapMaybe(f, mstr);

  // Maybe<関数>に対しては`mapApply`を使うことができます
  return applyMaybe(mf, mlen);
}

このように、まずは関数を1引数の関数に分解して[5]から、
mapMaybeapplyMaybeを組み合わせて使うことにより、
matchを直接使うことなく2引数の関数をリフトできます。

汎用的に使えるように、f: (t1: T1, t2: T2) => Uをリフトする関数を作っておきましょう。

function map2Maybe<T1, T2, U>(
  f: (t1: T1, t2: T2) => U,
  mt1: Maybe<T1>,
  mt2: Maybe<T2>,
): Maybe<U> {
  const f2 = (t1: T1) => (t2: T2) => f(t1, t2); // 実質2引数関数
  const mf1 = mapMaybe(f2, mt1); // 1引数関数(リフト)
  return applyMaybe(mf, mt2);
}

これを用いるとisLongerLiftedを次のように実装できます。

const isLongerLifted = (mstr: Maybe<string>, mlen: Maybe<number>) =>
  map2Maybe(isLonger, mstr, mlen);

(練習問題)
mt1のタグ、mt2のタグ、map2Maybeの返り値のタグの関係性を表にしてください。

(解答例)
mt1のタグ mt2のタグ map2Maybeの返り値のタグ
"Just" "Just" "Just"
"Just" "Nothing" "Nothing"
"Nothing" "Just" "Nothing"
"Nothing" "Nothing" "Nothing"

では3引数の場合はどうでしょうか?

function map3Maybe<T1, T2, T3, U>(
  f: (t1: T1, t2: T2, t3: T3) => U,
  mt1: Maybe<T1>,
  mt2: Maybe<T2>,
  mt3: Maybe<T3>,
): Maybe<U> {
  const f3 = (t1: T1) => (t2: T2) => (t3: T3) => f(t1, t2, t3); // 実質3引数関数
  const mf2 = mapMaybe(f3, mt1); // 2引数関数(リフト)
  const mf1 = applyMaybe(mf2, mt2); // 1引数関数(リフト)
  return applyMaybe(mf1, mt3);
}

2引数の場合と同様に、まず関数の引数を1つずつに分解し、初回だけmapMaybe
あとはapplyMaybeを繰り返し適用する[6]ことで機械的に実装することができます。

例えば、3つの文字列を整数としてパーズし、その和を計算してタグJustを付けます。
どれか1つでもパーズに失敗した場合はNothingとします。

const ma = safeParseInt("1");
const mb = safeParseInt("2");
const mc = safeParseInt("3");
const msum = map3Maybe((a, b, c) => a + b + c, ma, mb, mc); // Just(6)

3引数の関数と同じ要領で、n引数の関数をリフトすることができます。

(練習問題)
2つの文字列(整数であるべき)を引数にとり、それらで割り算をしてその結果を返す関数divideを作成してください。
割り算の結果は整数である必要はありません。
パーズにはsafeParseIntを使用し、パーズに失敗した場合はNothingを返します。
ゼロ除算時も同様にNothingを返します。

declare function divide(top: string, bottom: string): Maybe<number>;

top ÷ bottomを計算してください。

解答例`divide`
function divide(top: string, bottom: string): Maybe<number> {
  const mtop = safeParseInt(top);
  const mbottom = safeParseInt(bottom);
  const mbottomNonZero = filterMaybe((n) => n !== 0, mbottom);
  return map2Maybe((t, b) => t / b, mtop, mbottomNonZero);
}

(練習問題)
4引数の関数をリフトするmap4Maybe関数を作成してください。

declare function map4Maybe<T1, T2, T3, T4, U>(
  f: (t1: T1, t2: T2, t3: T3, t4: T4) => U,
  mt1: Maybe<T1>,
  mt2: Maybe<T2>,
  mt3: Maybe<T3>,
  mt4: Maybe<T4>,
): Maybe<U>;
解答例`map4Maybe`
function map4Maybe<T1, T2, T3, T4, U>(
  f: (t1: T1, t2: T2, t3: T3, t4: T4) => U,
  mt1: Maybe<T1>,
  mt2: Maybe<T2>,
  mt3: Maybe<T3>,
  mt4: Maybe<T4>,
): Maybe<U> {
  const f4 = (t1: T1) => (t2: T2) => (t3: T3) => (t4: T4) => f(t1, t2, t3, t4);
  const mf3 = mapMaybe(f4, mt1);
  const mf2 = applyMaybe(mf3, mt2);
  const mf1 = applyMaybe(mf2, mt3);
  return applyMaybe(mf1, mt4);
}

Maybe<T>とその中の値からMaybe<U>を作成する

map2Maybemap3Maybeなどでは、複数のMaybe<_>をその引数に要求します。
つまり、これらのヘルパー関数を使う際には、既に複数のMaybe<_>値を持っていなければなりません。
しかし、Maybe<_>の中身を使って新しい処理を順次行いたい場合があります。

例えば、文字列を整数にパーズし、それが妥当な年齢かどうかを確認します。
パーズに失敗したり、整数であってもそれが妥当な値ではなかったりした場合にはNothingとします。

これは2つの処理に分かれています。

  1. 文字列を整数にパーズする (rawInput: string) => Maybe<number>
  2. その整数を検証し、年齢として扱う (int: number) => Maybe<Age>

このような処理は、パーズ結果(整数)が無ければ妥当な年齢かどうかの判定ができないため、
map2Maybeを使うことはできません。

したがって、次のように書くことになります。

function parseAge(str: string): Maybe<Age> {
  return match(safeParseInt(str), {
    Just: ({ value }) => createAge(value),
    Nothing: (_) => Nothing,
  });
}

そこで、ヘルパー関数bindMaybeを定義します。

function bindMaybe<T, U>(mt: Maybe<T>, f: (t: T) => Maybe<U>): Maybe<U> {
  return match(mt, {
    Just: ({ value }) => f(value),
    Nothing: (_) => Nothing,
  });
}

これにより、parseAgeを次のように実装できます。

const parseAge = (str: string) => bindMaybe(safeParseInt(str), createAge);

bindMaybeはこれまでに定義してきたヘルパー関数の中でも
かなり器用な関数であり、様々なことをすることができます。

(練習問題)
JustbindMaybeを用いてmapMaybeを再実装してください。

(解答例)`mapMaybe`
const mapMaybe = <T, U>(f: (t: T) => U, mt: Maybe<T>) =>
  bindMaybe(mt, (t) => Just(f(t)));

(練習問題)
JustbindMaybeを用いてapplyMaybeを再実装してください。

(解答例)`applyMaybe`
const applyMaybe = <T, U>(mf: Maybe<(t: T) => U>, mt: Maybe<T>): Maybe<U> =>
  bindMaybe(mf, (f) => bindMaybe(mt, (t) => Just(f(t))));

Maybe<T>の方言

今回はMaybeJustNothingという言葉を採用しましたが、
それぞれOptionSomeNoneという言葉を採用する流派もあります。

applyの代わりにapという言葉を採用する流派もあります。
bindの代わりにchainandThenという言葉を採用する流派もあります。
関数の引数の順番が異なる場合がありますが、処理自体の本質は変わりません。

まとめ

今回は古典的ADTの1つとしてMaybe<T>を定義し、その周辺の典型的なヘルパー関数を定義しました。
もし本記事が参考になりましたら、いいねあるいはコメントよろしくお願いします。

脚注
  1. Coproduct<T>はタグが重複しないことを保証するので、これは非交和になります。 ↩︎

  2. プログラムを書く人の責任が無くなることはありません。「変数名が実体に合っていること」もその内の1つです。例えばindexOfBananaという名前の変数を「"banana"のインデックスを表す変数にしたい」という人間の気持ちはconst indexOfBanana = arr.indexOf("zucchini");などの記述によって踏み躙られてしまいます。 ↩︎

  3. したがって、finiteAbs(-42 as FiniteNumber)のように書かなければなりません。-42FiniteNumberであることを確認するのはプログラムを書く人の責任です。 ↩︎

  4. matchを使うと常に"Just""Nothing"の場合分けを明示的に記述しなければならず、そのメリットにもかかわらず時に煩雑になります。そこで、「"Nothing"のときはそのまま"Nothing"を返す」という処理を暗黙的に行うヘルパー関数を作成します。 ↩︎

  5. このように高階関数を駆使して、n引数の関数を1引数の関数のみで表すことをカリー化と言います。 ↩︎

  6. このようにmapapplyを主軸にコードを書くことをApplicativeスタイルと呼ぶことがあります。特に、ユーザーが自由に演算子を定義できる言語で使われます。 ↩︎

GitHubで編集を提案

Discussion