【ITニュース解説】A Brief Introduction to Functor for Busy TypeScript Devs
2025年09月29日に「Dev.to」が公開したITニュース「A Brief Introduction to Functor for Busy TypeScript Devs」について初心者にもわかりやすく解説しています。
ITニュース概要
Functorとは、データの中身に処理を適用し、その構造を保ったまま新しいFunctorを返すパターンだ。JavaScriptのArray.mapのように処理をチェーンできるため、コードの可読性が向上する。関数型プログラミングの基礎で、PromiseもFunctorの一種だ。
ITニュース解説
プログラミングの世界には、コードをより読みやすく、管理しやすくするための様々な考え方やパターンが存在する。その一つに「Functor(ファンクター)」という概念がある。これは一見難しそうに聞こえるかもしれないが、実はJavaScriptを使っていれば日常的に活用している可能性が高い、非常に身近なものだ。この記事では、Functorがどのようなもので、なぜ役立つのかを、具体的なコード例を交えながら初心者にもわかりやすく解説する。
まず、Functorの具体的な動作と利点を見てみよう。例えば、配列に格納された数値のそれぞれに、複数の計算を順に適用したいとする。JavaScriptの配列にはmapという便利なメソッドがある。このmapを使うと、配列の各要素に対して指定した関数を適用し、その結果を新しい配列として返すことができる。
仮に、[1, 2, 3]という配列があり、各要素に1を加えてから、さらにその結果を2乗したいとする。一般的なmapの使い方だと、以下のようになるだろう。
1const arr = [1, 2, 3]; 2arr.map((num) => num + 1); // 結果: [2, 3, 4]
これに続けて2乗する操作を加えたい場合、mapメソッドを複数回連結して書くことができる。
1const arr = [1, 2, 3]; 2arr.map((num) => num + 1).map((num) => num ** 2); // 結果: [4, 9, 16]
計算ロジックを独立した関数として定義すると、さらに読みやすくなる。
1const arr = [1, 2, 3]; 2const add1 = (num: number) => num + 1; 3const square = (num: number) => num ** 2; 4arr.map(add1).map(square); // 結果: [4, 9, 16]
このようにmapを連結して書くことで、「配列の値にまずadd1を適用し、次にsquareを適用する」という一連の処理が、左から右へ読むだけで理解できる。これが、Functorが持つ「操作を連続的に連結できる」という特性であり、コードの読みやすさを大きく向上させる。
一方、このような連結(チェイン)を使わない場合、同じ処理はどのように書かれるだろうか。例えば、以下のようにネストした関数として書く方法がある。
1arr.map(num => square(add1(num))); // 結果: [4, 9, 16]
あるいは、複数の操作を適用したい場合は、さらに深く関数をネストすることになる。
1const times2 = (num: number) => num * 2; 2arr.map(num => times2(square(add1(num)))); // 結果: [8, 18, 32]
このネストされた書き方では、最も内側の関数から処理が実行され、その結果が外側の関数に渡されていくため、コードを解読する際に思考を逆転させる必要がある。また、括弧の数が増えるほど視覚的な複雑さも増し、どの値がどの関数に渡されているのかを追うのが難しくなる場合がある。Functor的なmapの連結は、この複雑さを解消し、コードを素直に「左から右へ」読めるようにしてくれる。
パフォーマンスに関して言えば、mapを連結した場合、各map呼び出しで配列が一度ずつループ処理される。ネストした関数の場合は単一のループ内で複数の操作が実行される。しかし、どちらの方式も配列の要素数に比例して処理時間が増える(計算量オーダーがO(n))ため、ほとんどのケースではパフォーマンス上の大きな違いはない。読みやすさの向上というメリットが、わずかな処理コストの差を上回ることが多いのだ。
では、一体Functorとは何なのだろうか。Functorは、より抽象的な「カテゴリ理論」という数学の分野にルーツを持つ概念だ。プログラミングの文脈では、Functorとは「mapメソッド(またはそれに相当する機能)を持つデータ型やコンテナ」と定義できる。このmapメソッドを使うと、コンテナの中身の値を変換できるが、コンテナ自体の「構造」は保たれる。つまり、mapを適用しても、結果はまたFunctorとなり、さらにmapを連結して操作を続けることができる。
Functorとして機能するためには、以下の2つの基本的な法則に従う必要がある。
-
同一性法則 (Identity Law) もし、Functorの
mapメソッドに「入力された値をそのまま返す」関数(恒等関数、例:x => x)を渡した場合、返されるFunctorは元のFunctorと「等価」である必要がある。1const arr = [1, 2, 3]; 2const identityFunction = (x: number) => x; 3arr.map(identityFunction); // 結果: [1, 2, 3]ここで「等価」とは、中身の値が同じであるという意味だ。
mapは新しい配列インスタンスを作成するため、オブジェクトとしては別物になる点に注意が必要だ。 -
合成法則 (Composition Law) 複数の関数をネストして一度に適用するのと、それぞれの関数を
mapメソッドで順に連結して適用するのとで、結果が同じになる必要がある。 先ほどの例で見たように、arr.map(num => times2(square(add1(num))))とarr.map(add1).map(square).map(times2)は、どちらも同じ[8, 18, 32]という結果を返す。 これを一般化すると、F.map(x => f(g(x)))はF.map(g).map(f)と等価である、と表現できる。ここでFはFunctor(今回の例では配列)を指す。この法則のおかげで、私たちは操作の記述スタイルを自由に選ぶことができ、コードの読みやすさを追求できるのだ。
JavaScriptの配列だけでなく、PromiseもFunctorの一種だ。Promise.resolve(2).then(add1).then(square).then(times2)のように.then()を連結することで、非同期処理の値を順に加工していくことができる。これもFunctorの合成法則が適用されている例だ。
私たちは、自分自身のFunctorを定義することもできる。Functorを作成するには、以下のステップを踏む。
- 値を保持するデータ型(コンテナ)を作成する。
mapメソッドを実装する。このメソッドは、引数としてコールバック関数を受け取り、その関数をコンテナ内部の値に適用する。mapメソッドは、コールバック関数の結果を新しいFunctorインスタンスとしてラップして返す。これにより、コンテナの構造が保たれ、さらに操作を連結できるようになる。
非常にシンプルなJavaScriptの例を見てみよう。
1const functor = (value) => ({ 2 map: (callback) => functor(callback(value)), 3 get: () => value 4});
このfunctor関数は、ある値を受け取り、mapメソッドとgetメソッドを持つオブジェクトを返す。mapメソッドはコールバック関数を値に適用し、その結果で新しいFunctorインスタンスを作成して返している。
TypeScriptで型安全に、より柔軟なFunctorを作ることも可能だ。ジェネリクス(型パラメータ)を使うことで、Functorが扱う値の型を抽象化できる。
1interface IFunctor<T> { 2 map: <U>(callback: (val: T) => U) => IFunctor<U>; 3 get(): T; 4} 5 6const functor = <T>(value: T): IFunctor<T> => ({ 7 map: <U>(callback: (val: T) => U): IFunctor<U> => functor(callback(value)), 8 get: () => value, 9}); 10 11// 使用例 12const exclaim = (word: string) => word + '!'; 13const capitalize = (word: string) => word.charAt(0).toUpperCase() + word.slice(1); 14const words = functor("i'm on a boat"); 15 16words.map(exclaim).map(capitalize).get(); // 結果: "I'm on a boat!"
この例では、文字列をFunctorで包み込み、exclaimとcapitalizeという二つの関数を順に適用している。get()メソッドを呼び出すことで、最終的な加工済みの値を取り出すことができる。
オブジェクト指向プログラミング(OOP)に慣れている人であれば、クラスを使って同様のメソッドチェインを実現できることに気づくだろう。クラスのメソッド内でthis(現在のオブジェクト自身)を返すことで、メソッドを連結して呼び出すことが可能になる。
1class MyFunctor<T> implements IFunctor<T> { 2 constructor(private value: T) {} 3 4 map<U>(callback: (val: T) => U): IFunctor<U> { 5 this.value = callback(this.value) as unknown as T; // 内部状態を変更 6 return this as unknown as IFunctor<U>; 7 } 8 9 get(): T { 10 return this.value; 11 } 12} 13 14// 使用例 15const wordsOOP = new MyFunctor("i'm on a boat"); 16wordsOOP.map(exclaim).map(capitalize).get(); // 結果: "I'm on a boat!"
ご覧の通り、関数型アプローチで作ったFunctorでも、OOPのクラスベースでthisを返す方法でも、同じようにメソッドチェインを実現し、同じ結果を得られる。どちらも異なるプログラミングパラダイム(考え方)から、操作の連結という同じ目標を達成する手段だと言える。
しかし、両者には重要な違いがある。特に「関数型プログラミング」の文脈で考えるならば、「副作用(Side Effect)」の有無が鍵となる。クラスベースのMyFunctorの例では、mapメソッド内でthis.value = callback(this.value)という形で、オブジェクトの内部状態(value)を直接変更している。これを「ミューテーション(状態の変更)」と呼ぶ。状態が変更されると、予期せぬ場所でその値が書き換えられたり、追跡が難しくなったりするリスクがある。
一方、関数型のFunctorの例では、mapメソッドが常にfunctor(callback(value))という形で「新しいFunctorインスタンス」を返している。元のFunctorの状態は一切変更されない。この特性を「イミュータビリティ(不変性)」と呼び、これによりコードの予測可能性が高まり、バグの発生を抑えやすくなる。
Functorという概念は、単にコードを連結して書けるという利便性だけでなく、コードの設計や保守性にも深く関わる考え方だ。特にイミュータビリティを保つことで、複雑になりがちなプログラムの状態管理をよりシンプルに、安全に行うための第一歩となる。Functorは、より読みやすく、より堅牢なコードを書くための強力なツールなのだ。