値が無いかもしれない、を`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
も「値」
私たちは普段、null
やundefined
を「値の欠損」を表現するための値として利用していますが、これらも「値」である以上「値の欠損」の表現としては便宜的なものに過ぎない、という見方もできると思いませんか?
つまり、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
です。そのためh
がnull
のとき、それが「先頭の要素が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
関数によって隠蔽し、利用側では「値があったら何をするか」のみを考えて実装を進めることができています。
このような抽象的で汎用性のある実装は様々な場面で利用されることを想定しなくてはならないので、いくつか前の項で話した通り、有効なものとして扱いたい値がnull
やundefined
の場合、というエッジケースも考慮する必要があり、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、および関数型プログラミングの魅力の一つだと思ってます。
Discussion