aknow2

自分の興味のある事を連々と(プログラミング、モバイルアプリ、プログラミング教育)

【typescriptで関数型】fp-tsのOption型を使ってundefinedと戦う

この記事はOOP思考でずっとやってきた人が、どうにかFPの流れにのっていくために足掻いている記事です。なので、随所にOOPの場合はこんなイメージだよねというのを書いてます。
関数型の知識的にはすごいH本を斜め読み、Elmのtutorialをやってみた程度です。

まず課題感。
APIを呼び出した結果がJSONの中にundefinedになるパラメーターがある場合、またはアプリの状態,例えばReduxやVuexのStoreにundefinedが合った方が都合が良い時。

イメージとしてはこんな感じ。

interface OptionalState {
  param? : number
}

if (st1.param !== undefined && st2.param !== undefined ) { // チェックするのが面倒
  const result = st1.param + st2.param
}  

何かするたびにチェックするのはとってもダルイ。また、条件分岐が増えるとコードを見返した時に条件を確認しなければならないので可読性が損なわれます。
ビジネスロジックとしての条件分岐であれば良いかもしれませんが、nullやundefinedのチェックの分岐は本当に必要なのか良く分からず前後の文脈も理解する必要があり面倒なのでなるべく関わりたくないです。

この問題を関数型プラグラミングの考え方の一つであるOption型を使って解決してみようと思います。ライブラリとしてtypescirptの関数型ライブラリのfp-tsを使用します。

gcanti.github.io 必要な人はnpm or yarnでインストールしてください。
npm install --save fp-ts

プリミティブな現実世界からの脱出

先の例で出したOptionalStateを元に進めていきましょう。 OptionalStateはただのInterfaceなので、instanceを作ります。名前はsomeStateとしてparamに10を初期値として持つ事にします。
こんな感じ です。

interface OptionalState {
  param? : number
}
const someState: OptionalState = {
  param: 10 // 値は10を持つ
}

someState.paramnumber | undefined型です。
この型のままではundefinedチェックをせざるを得ないプリミティブな世界なので、
この値をfp-tsのfromNullableを使う事でOption型の世界へいざないます。

import * as O from 'fp-ts/lib/Option'

interface OptionalState {
  param? : number
}
const someState: OptionalState = {
  param: 10
} 
// Option型の世界へようこそ
const some: O.Option<number> = O.fromNullable(someState.param)

f:id:aknow2:20190326140421p:plain:w600
イメージ

fromNullableはNullやundefined型を取りうる値を渡すとOption型に変換してくれるものです。
Option型完成!これでプリミティブで現実的な世界から、nullチェック不要の夢の世界に来る事が出来ました。やったね!
。。。はい、でOption型、これは何でしょうか?何が良いのでしょうか?
Option型の型情報を見てみると、、、
type Option<A> = O.None<A> | O.Some<A>
None又はSomeを持つ型となっています。
ここでいきなり関数型の世界の決まり事が登場します。一般的にSomeは何かしら値が入ってる事を指し、Noneは値が入っていない事つまり、nullやundefinedが入っている事を指しています。
関数型の夢の世界では良くあるパターンでそういう物だと思ってください。

先ほど定義したsomeは, O.Option型と定義していますが、実際には値10を内包したSomeなOption型です

const someState = {
  param: 10
} as OptionalState
// someState.param => 10つまり、値が存在するのでSome型のOptionになる
const some: O.Option<number> = O.fromNullable(someState.param) // O.Some 10

次にparamがundefinedであるnoneStateを作りましょう。
これもfromNullableを使ってOption型に変換します。
paramはundefinedなので、実際にはNone型になります。

const noneState : OptionalState = {
  param: undefined
} 

// someState.param => undefined 値が存在しないのでNone型になる
const none: O.Option<number> = O.fromNullable(noneState.param) // O.None

f:id:aknow2:20190326140810p:plain:w600
SomeとNoneについてさらに詳しく見ていきましょう。
実装を見ると、両方にまったく同じメソッドが実装されています。
なので、Option型を使う側はNoneであろうがSomeであろうが気にする事なく実装をする事が出来ます。 OOPで言うNullObjectパターンに近いと思います, イメージ的にはSomeとNoneはOption型のインターフェースを実装していると言う概念に近いと思います。(fp-tsのOption型はSomeとNoneでまったく別の実装でメソッド名や引数を揃えているだけなので、継承とは違いますが概念的には似ているという事でお願いします)
要はプリミティブな値をOption型でラップして抽象化する、そして、nullやundefinedをNone、値があるのをSomeと分ける。だけど、共にOption型なので使う側はNoneあろうがSomeであろうが気にする事なく実装出来てしまうと言う事です。 では、作成したOption型のsomenoneを使ってnullチェックの無い世界を作っていってみましょう。

プリミティブな現実世界へ戻る。デフォルト値

最初の例としては適当では無いかもしれませんが、Option型の世界から抜け出す方法を最初に説明します。
それは値が無ければデフォルト値を返すパターンです。

// null or undefinedの場合はデフォルト値(0)を返す
const getDefaultValue = (opt: O.Option<number>): number => {
  return opt.getOrElse(0) // optがnoneであれば0を返す
}
{
  const someResult = getDefaultValue(some) // 10
  const noneResult = getDefaultValue(none) // 0
}

f:id:aknow2:20190326140911p:plain:w600
Option型のgetOrElseを使うとNone型の場合にデフォルト値を返す事が出来ます。
このgetOrElseはデフォルト値を取得するというより、Option型の夢の世界から現実的なプリミティブな型の世界へ戻すために、仕方なくデフォルト値を設定するというイメージのが近いです。

条件分岐が無く、関数型らしく宣言的にデフォルト値を取得する事が出来ていると思います。
とはいえ、これだけだと3項演算子だけでいいじゃんとなりそうなので次の例に行きましょう

値がある場合は処理をする

次の例はNoneで無ければ「〇番目」という文字列を返し、無ければ「-」を返します。

// 値がある場合は「〇番目」という文字列を返し無ければ「-」で返す。
{
  const format = (opt: O.Option<number>): string => {
    return opt
            .map(num => `${num}番目`) //optがSomeであれば実行される
            .getOrElse('-') // optがNoneであればデフォルト値を返す
  }
  const someResult = format(some) // 10番目
  const noneResult = format(none) // -
}

map関数を使う事で、Option型に内包されている値をいじる事が出来ます。
さらに!map関数はSomeの時だけ実行され、Noneの場合はスキップされます。
getOrElseを除き、Option型に用意されているほとんどの関数群は基本的にOption型を返す様に出来ています。
このmap関数の戻り値はStringを返しているので、O.Option<string>型になります。 最後にgetOrElse使ってデフォルト値を設定し、Option型の世界から抜けています。 f:id:aknow2:20190326140955p:plain:w600

次に値が存在した場合は2倍し、無ければNoneを返すdoubleという関数を実装してみます。

// 値がある場合は2倍する。
const double = (opt: O.Option<number>): O.Option<number> => {
  return opt.ap(O.some((num: number) => num * 2))
}
{
  const someResult = double(some) // O.some<number> 20
  // 関数型の世界から値を取り出す 
  getDefaultValue(someResult) // 20
  const noneResult = double(none) // O.none<number>
  getDefaultValue(noneResult) // 0
}

Someの場合はap(applyという意味)の中が実行され、Noneの場合は実行されません。 apの実行結果も例によってOption型であるため、double関数の戻り値はOption`型になります。 double関数の引数がOption型、戻り値もOption型で完全にdouble関数はOption型の世界の住人と言えるでしょう。

プリミティブな値を返さずにOption型で返したのには理由があります。Option型の世界の住人は, Option型同士でNullやUndefinedを意識することなく一緒に使えるからです。
どういう事か知るために、Option型の住人もう一つ増やして見ましょう。
値をインクリメントする関数を作ります。

{
  const increment = (opt: O.Option<number>): O.Option<number> => {
    return opt.map(v => v+1)
  }
  const someResult = increment(some) // O.some 11
  const noneResult = increment(none) // O.none
}

さて、increment関数も引数、戻り値が共にOption型の世界の住人です。
なので、先ほどのdouble関数と共に使用する事が出来ます。 例えば、incrementしてから2倍するという実装をしてみましょう。

const incrementResult = increment(some) // O.some 11
double(incrementResult) // O.some 22

double(increment(none)) // O.none

Option型の夢の世界に居る限り、値があろうが無かろうが無視して実装する事が出来るのです! f:id:aknow2:20190326141308p:plain:w600

今までは、一つのOption型に対する実装であったので、Option型同士で処理をするパターンを見ていきましょう
Option型の引数を二つとり単純に足し算をする例です。

  const add = (opt1: O.Option<number>, opt2: O.Option<number>): O.Option<number> => {
    return opt1.chain(v1 => { // opt1がSomeであれば実行される
      return opt2.map(v2 => { 
        return v1+v2 // opt2がSomeであれば実行される
      })
    })
  }

chain関数もmapと同じ様にSomeの場合のみにしか実行されません。
ただし、戻り値はOption型です。
戻り値がOption型である事を利用して二つ目の引数opt2のmap関数を呼び出し、値の足し算を行っています。
f:id:aknow2:20190326141352p:plain:w600

実際に使ってみましょう。

  add(some, some) // some 20
  add(some, none) // none

  // もちろん、作ってきたdoubleやincrementと一緒に使えます!
  add(double(some), some) // some 30
  add(double(some), increment(some)) // some 31
  add(double(none), some) // none

add関数にNone型が一つでも入ると結果がNoneになります。
Option型の世界に居れば、条件分岐をまったく行わずに実装出来ている事が分かります。

次を最後とします。
ここまでの例はNone型の場合は処理が実行されずに無視されてきました。
場合によっては、無視せずにNone場合でもデフォルト値使って処理を続行したい時があると思います。そこで、足し算を行う時に引数にNone型が渡され場合、Some(0)に変換して計算される仕組みを実装してみます。

  const add2 = (opt1: O.Option<number>, opt2: O.Option<number>): O.Option<number> => {
    return opt1
            .alt(O.some(0)) // Opt1がNoneの場合はSome(0)として扱う
            .chain(v1 => { // 絶対に実行されるようになる。
              return opt2
                .alt(O.some(0))// Opt2がNoneの場合はSome(0)として扱う 
                .chain(v2 => {// 絶対に実行されるようになる。
                    return O.some(v1 + v2) // 最後に足し算
                })
             })
  }

  const someResult = add2(some, none) // some 10
  getDefaultValue(someResult) // 10
  add2(none, none) // some 0

altはNone型を受け取った場合にデフォルト値を設定する事が出来ます。
ただし、getOrElseと違い戻り値がOption型になるのでOption型の世界でメソッドチェーンで処理を繋げていけます。
これもまた、条件分岐なしに実装が出来ていますね。

おしまい。

という事でOption型だけを使って、条件分岐ない世界を表現してみました。
これでundefinedチェックから解放され処理を自由に組み替えながら実装が出来るはず(投げやり)