🧐

値が無いかもしれない、を`Option<T>`で表す!`T | null`との違いは?🤔

に公開

はじめに

こんにちは。最近fp-tsを用いた関数型プログラミングにハマっているs.katoです。

そんなfp-tsについてキャッチアップをするときに割と最初に触れた概念(型?表現?)であるOptionについて、今回は紹介したいと思います!

まずはfp-tsにおけるOptionの定義から

export interface None {
  readonly _tag: 'None'
}

export interface Some<A> {
  readonly _tag: 'Some'
  readonly value: A

export type Option<A> = None | Some<A>

Optionは任意の型引数Aをもち、「値が無いかもしれない」を「判別可能なユニオン型(Discriminated Union Type)」で表現している型といえるでしょう。
(タイトルや本文では型引数をTと記述していますが、これはこちらの方が型引数名としてより一般的で伝わりやすいかなと個人的に思ったためです)

同じような表現、T | nullでもできるよ?🤔

皆さんは「値が無いかもしれない」を普段どのように表現していますでしょうか?自分はT | nullで表現してたので、さらにこれをジェネリックな型エイリアスとして

type Maybe<T> = T | null

こんな感じに定義して運用してました。

TypeScriptは型推論が優秀なのでこのような型を用意するだけで大体問題なくnullのハンドリングもできるし、「値が無いかもしれない」を表現し、扱えますよね。

function foo(mn: Maybe<number>): string {
  if (mn === null) {
    return '-'
  }

  return `[${mn}]` // mn: number
}

むしろ、同じようなコードを書くならOptionを使うパターンの方が冗長に見えるかも。

function foo(mn: Option<number>): string {
  if (mn._tag === "None") {
    return '-'
  }

  return `[${mn.value}]` // mn: Some<number>
}

nullも「値」

私たちは普段、nullundefinedを「値の欠損」を表現するための値として利用していますが、これらも「値」である以上「値の欠損」の表現としては便宜的なものに過ぎない、という見方もできると思いませんか?
つまり、nullが「意味を持つ値」として使われる場面では、Maybe<T>(=T | null)は「値が無いかもしれない」という意味をうまく担えなくなってしまうということです。

例えば任意の型の配列の先頭の要素があったらそれを、配列の要素数が0であれば「値の欠損」の表現のためnullを代わりに返すという、汎用的な関数を実装したとします。

function head<T>(of: T[]): Maybe<T> {
  if (of.length === 0) {
    return null
  }
  return of[0]
}

それをこういう風に呼び出すとどうでしょうか

declare const array: Array<number | null>

const h = head(array)
console.log(h) // h: Maybe<number | null>

TypeScriptが賢いのでMaybe<number | null>number | null | nullと展開されてnumber | nullに縮約されるようなことは起こりませんが、実行時の値としてのnullはただのnullです。そのためhnullのとき、それが「先頭の要素がnullだった」のか「配列が空だった」のかは、値だけを見ても判別できません。(今回の例ではarrayの宣言とheadの呼び出し、結果の読み取りが地続きで書かれているのでarray.lengthを見ればその辺の判別は可能ですが)

Option<T>は本質的に「値が無い」を表現している

改めて、fp-tsにおけるOptionの定義を見返してみましょう

export interface None {
  readonly _tag: 'None'
}

export interface Some<A> {
  readonly _tag: 'Some'
  readonly value: A

export type Option<A> = None | Some<A>

Some<A>に対して、None_tagプロパティのみしか持たず、中身の値を表すvalueが「無い」ですよね🧐
また構造体によって定義しているので、Option<Option<T>> のようなネストも問題なく表現できます。

declare function head(of: number[]): Option<number>
declare function divide(a: number, b: number): Option<number>
declare const array: number[]
declare const factor: number
declare const none: Option<never>
declare const some: <T>(of: T) => Option<T>

// `a` が None なら計算はできないのでそのまま`None`を返す
// 逆に `a` が`Some`のときは、その値を使って`divide`を実行する。
// `divide`の結果(Option<number>)を`Some`で包むことで、
// 「divideIfAIsSome 全体としては値がある(=`Some`)けれど、
// 中身の`Option<number>`が`Some/None`のどちらかである」ことを表現している。
function divideIfAIsSome(a: Option<number>, b: number): Option<Option<number>> {
  if (a._tag === "None") {
    return none
  }

  return some(divide(a.value, b))
}

const h = head(array) // h: Option<number>
const d = divideIfAIsSome(h, factor) // d: Option<Option<number>>

Maybe<T>で表現した時と異なり、Optionのネストによりどの時点から値が欠損していたのかを後続のコードからでも追うことができます。

「値が無いかもしれない」をいい感じに抽象化するとこんなこともできる🥳

以下のような関数の実装について考えてみます

declare function tryParseURL(url: string): Option<ParsedURL>
declare function tail<T>(of: readonly T[]): Option<T>
declare function splitString(by: string, s: string): readonly string[]
declare function isNone<T>(o: Option<T>): o is None
declare const none: None
declare function some<T>(value: T): Option<T>

function extractExtensionFromURLString(url: string): Option<string> {
  const parsed = tryParseURL(url)
  if (isNone(parsed)) {
    return none
  }
  const path = parsed.value.path
  const tailOfPath = tail(splitString('/', path))
  if (isNone(tailOfPath)) {
    return none
  }
  const extension = tail(splitString('.', tailOfPath.value))
  return extension
}

このように「無いかもしれない」値を扱うとき、往々にしてif文による欠損チェックと代替値の早期返却をすると思うのですが、こちらとしては「値があったときにこういう変換処理をしたい」を書きたいのに値が「無いかもしれない」せいで毎回チェックを書かなきゃいけないの煩わしいよなあ。と思ったことはありませんか?

そこで、Optionという「無いかもしれない値」を抽象化した型を用いてこんな実装を考えてみます。

function map<A, B>(a: Option<A>, f: (a: A) => B): Option<B> {
  if (isNone(a)) {
    return none
  }
  return some(f(a.value))
}

第一引数にOptionを受け取り、第二引数で「中身があったらそれを使ってしたい処理」を受け取ります。実装部分では、第一引数のチェックと条件に応じて第二引数の関数の適用を行っていますね。
そしてこれはこのように利用できます。

declare const url: string
const parsed = tryParseURL(url) // parsed: Option<ParsedURL>
const path = map<ParsedURL, string>(parsed, p => p.path) // path: Option<string>

これは気持ち良い、、!
「値が無いなら何もしない」をmap関数によって隠蔽し、利用側では「値があったら何をするか」のみを考えて実装を進めることができています。
このような抽象的で汎用性のある実装は様々な場面で利用されることを想定しなくてはならないので、いくつか前の項で話した通り、有効なものとして扱いたい値がnullundefinedの場合、というエッジケースも考慮する必要があり、Maybe<T>では破綻してしまいます🤯

おわりに

以上、Option<T>という型の紹介と、T | nullと比較した時の優位性に関する自分なりの考えでした。
初めてOption<T>を知った時、自分は「T | nullと何が違うんだ?こっちの方がシンプルで良くないか?」という疑問を持ち、色々考えているうちに前述したような結論に行きついたのでできるだけ当時の思考過程をそのままに記事に起こしてみました。

この記事が「無いかもしれない値」への向き合い方について考えるきっかけになったり、Option<T>という興味深い型表現の良さを知るきっかけになれたらうれしいです🥳

[おまけ] fp-tsを用いた応用

先ほど紹介したmapはfp-tsでもっといい感じに実装されていて、同じく提供されているpipeという関数を合成するための関数を使うと最終的にこんな感じの実装ができます。

import * as O from 'fp-ts/lib/Option'
import { pipe } from 'fp-ts/lib/function'

declare function tryParseURL(url: string): Option<ParsedURL>
declare function tail<T>(of: readonly T[]): Option<T>
declare function splitString(by: string, s: string): readonly string[]

function extractExtensionFromURLString(url: string): O.Option<string> {
  return pipe(
    url,
    // string -> Option<ParsedURL>
    tryParseURL,
    // Option<ParsedURL> -> Option<string>
    O.map(parsed => parsed.path),
    // Option<string> -> Option<readonly string[]>
    O.map(p => splitString('/', p)),
    // Option<readonly string[]> -> Option<Option<string>> -> Option<string>
    O.flatMap(tail),
    // Option<string> -> Option<readonly string[]>
    O.map(t => splitString('.', t)),
    // Option<readonly string[]> -> Option<Option<string>> -> Option<string>
    O.flatMap(tail),
  )
}

if文なくなっちゃった🤯
こういう風に「値の変換」に注力して処理をつなぐように実装ができるのがfp-ts、および関数型プログラミングの魅力の一つだと思ってます。

ispec inc.

Discussion