DEV Community

e_ntyo
e_ntyo

Posted on • Updated on

小規模なSPAをつくってみてわかったFable(F# -> JS)の良いところ

突然ですが私、「論文等の書誌引用データのフォーマットを変換するための SPA」をつくりました✌️

RIS形式の書誌引用データをSIST02形式に変換するツールをつくった - いいんちょのブログ

開発には Fable という AltJS を使ったのですが、Fableは素晴らしいツールであるにもかかわらず、あまりWeb開発者に知られていない印象を受けます。この機会にFableを紹介する日本語記事を書こうと思います💁‍♂️

Fable とは

Fable is a compiler powered by Babel designed to make F# a first-class citizen of the JavaScript ecosystem
https://fable.io/

Fable は F#のコードを Babel compatible な AST に変換するツールです。つまり TypeScript のように、最終的に JavaScript のコードに変換されるプログラミング言語を書くことができます。

F#の良いところについては、@cannorinさんの「F# を知ってほしい」が詳しいです。

この記事では、Fable の良いところ、すなわち F#を Web フロントエンド開発で使うとどのような点が嬉しいのかなどについて書いていきます。また、Fable というツールそのものの良い点にも触れていきます。

Fable(F#) のよいところ

Fable でなくても、TypeScript という非常に便利で手軽で強力な AltJS がありますし、他にも Elm や ReasonML、PureScript があります。ただし、私は Elm, ReasonML その他の AltJS による開発の経験がないため、以降でも比較対象には TypeScript と PureScript が使われます。あしからず 🙇‍♂️

私個人が Fable を選んだ理由は、以下の条件を満たす AltJS が欲しかったためです。

  1. TypeScript と PureScript の中間くらいの抽象度で
  2. TypeScript で普段感じている不満点を解消してくれて
  3. ESModules な JS のコードを出力可能で
  4. Language Server の実装やいい感じのビルドツールが存在する

1. 適当な抽象度

この項目については、私が過去に PureScript を使って SPA を開発した際の経験に基づいています。

PureScript は Row Polymorphism などを実現するための非常に高度な型システムを備えていますが、そのせいか公開されているライブラリを使って開発しようとするとコンパイルを通すことがとても大変で、正直フラストレーションを感じることもありました。

PureScript には TypeScript のような漸進的型付けの仕組みはありませんし、 tsconfig.jsoncompilerOptions ようにコンパイル時の設定を細かく設定するということもできないため、これは私にとっては大きな問題でした。むしろ Haskell のバックグラウンドをお持ちの方や、言語仕様の学習にたくさんの時間を確保できる方にとっては非常に良い選択肢だと考えています。

F#にも漸進的片付けの仕組みはありませんが、以下の理由から PureScript のコードを書くときほどの頭の痛さはありませんでした。

2. TypeScript で普段感じている不満

私は普段 TypeScript を書いていて、開発体験にある程度満足しているのですが、「もっとこうなってほしい」と思う点がいくつかあります。

  1. パターンマッチがほしい
  2. Template literals(sprintf)は Type Safe であってほしい
  3. 関数合成が手軽にできてほしい

ただし、PureScript や ReasonML でもこれらの不満は解消されます。

2.1 パターンマッチ

まずパターンマッチについてですが、TypeScript でも次のようにして書くことはできます。

function maybe<A, B>(
  whenNone: () => B,
  whenSome: (a: A) => B,
  fa: Option<A>
): B {
  switch (fa._tag) {
    case "None":
      return whenNone();
    case "Some":
      return whenSome(fa.value);
  }
}

(コードはfp-ts のドキュメント内のものです)

ただし、JavaScript における Switch は式ではなく文ですから、パターンマッチ部分の処理を変数に束縛できません。
fp-ts の作者 @gcanti さんのブログ記事では、もう少しパターンマッチを宣言的に書く方法("'poorman' pattern match")が紹介されています。 List では onNilonConsOption では onSomeonNone の時に評価される関数を定義して使う、という考え方です。

//        ↓ type parameter
type List<A> = { type: "Nil" } | { type: "Cons"; head: A; tail: List<A> };
//                                                              ↑ recursion

const fold = <A, R>(
  fa: List<A>,
  onNil: () => R,
  onCons: (head: A, tail: List<A>) => R
): R => (fa.type === "Nil" ? onNil() : onCons(fa.head, fa.tail));

const length = <A>(fa: List<A>): number =>
  fold(
    fa,
    () => 0,
    (_, tail) => 1 + length(tail)
  );

(コードはFunctional design: Algebraic Data Types - DEV Community 👩‍💻👨‍💻中のものです)

このやり方の欠点は代数的データ型 1 つにつきこういった関数を毎回用意しなければならないということです。fp-ts では Option , Either などの代数的データ型については fold などを提供しており便利ですが、自前で代数的データ型を宣言した際にはやはり対応した fold を定義して使うことになります。

一方で、F#は言語仕様としてパターンマッチを定義しているため、自前の代数的データ型についてもふつうにパターンマッチを使うことができます。しかも、以下の match は文でなく式です。

[<Literal>]
let Three = 3

let filter123 x =
    match x with
    // The following line contains literal patterns combined with an OR pattern.
    | 1 | 2 | Three -> printfn "Found 1, 2, or 3!"
    // The following line contains a variable pattern.
    | var1 -> printfn "%d" var1

for x in 1..10 do filter123 x

(コードはF#のドキュメント内のものです)

2.2 Template literals(sprintf)は Type Safe であってほしい

これは言語仕様というよりはもう少し具体的な、特定の機能についての話ですが、ES2015 specification から導入された Template literals を TypeSafe に扱いたいという思いがあります。

(以下のコードの一部に誤りがあったことを @otofuneから教えて頂き、修正しました。)

const displayName = "e_ntyo";
const user = { displayName };
const tagged = `Hello! I am ${displayName}`; // "Hello! I am e_ntyo"
const tagged2 = `Hello! I am ${user}`; // "Hello! I am [object Object]" <- 勝手にtoString()されている

FSharp ではこの機能は sprintf という関数として提供されており、Type-Safe です。

type User = { DisplayName: string }

let displayName = "e_ntyo"
let user = { DisplayName = displayName; }
let tagged = displayName |> sprintf "Hello! I am %s"; // "Hello! I am e_ntyo"
let tagged2 = user |> sprintf "Hello! I am %s"; // The type 'User' does not match the type 'string'

2.3 関数合成が手軽にできてほしい

TypeScript では関数合成がやや面倒で、Type-Safe にやっていくためには次のような合成用関数を定義する必要があります。

function compose<A, B, C>(f: (arg: A) => B, g: (arg: B) => C): (arg: A) => C {
  return x => g(f(x));
}

interface Person {
  name: string;
  age: number;
}

function getDisplayName(p: Person) {
  return p.name.toLowerCase();
}

function getLength(s: string) {
  return s.length;
}

// has type '(p: Person) => number'
const getDisplayNameLength = compose(getDisplayName, getLength);

// works and returns the type 'number'
getDisplayNameLength({ name: "Person McPersonface", age: 42 });

(コードはTypeScript のドキュメントからです。)

F#では、関数合成用のオペレータが最初から提供されており、より簡潔に記述できます。

// Write code or load a sample from sidebar
type Person = {
    Name: string;
    Age: int;
}

let getDisplayName (p: Person) =
  p.Name.ToLower()

let getLength (s: string) =
  s.Length

let getDisplayNameLength = getDisplayName >> getLength

getDisplayNameLength { Name = "Person McPersonface"; Age = 42 };

2 つ以上の関数を合成する場合でも、コードは綺麗です。

compose(compose(compose(f, g), h), i);
f >> g >> h >> i

3. target の Module System

AltJS としては、コードを書いている際に、「こういう JS のコードにコンパイルされるのだろうな」というイメージができると理想的です。

例えば、以下の PureScript のコードをトランスパイルするとします。

module Main where

import Prelude

import Data.Either (Either(..))
import Data.Maybe (Maybe(..))
import Effect (Effect)
import Effect.Console (log)

f :: Maybe Boolean -> Either Boolean Boolean -> String
f a b = case a, b of
  Just true, Right true -> "Both true"
  Just true, Left _ -> "Just is true"
  Nothing, Right true -> "Right is true"
  _, _ -> "Both are false"

main :: Effect Unit
main = do
  log $ f (Just true) (Right true) -- Both true

このような CommonJS モジュールの JS のコードに変換されます。
(※@oreshinyaさんから「掲載されているコードは CommonJS モジュールのコード( purs build によって生成されるコード)ではなく、 purs bundle によって生成されるiife形式のコードになっている」というご指摘を頂き修正いたしました。)

// Generated by purs version 0.13.6
"use strict";
var Data_Either = require("../Data.Either/index.js");
var Data_Maybe = require("../Data.Maybe/index.js");
var Effect_Console = require("../Effect.Console/index.js");
var f = function (a) {
    return function (b) {
        if (a instanceof Data_Maybe.Just && (a.value0 && (b instanceof Data_Either.Right && b.value0))) {
            return "Both true";
        };
        if (a instanceof Data_Maybe.Just && (a.value0 && b instanceof Data_Either.Left)) {
            return "Just is true";
        };
        if (a instanceof Data_Maybe.Nothing && (b instanceof Data_Either.Right && b.value0)) {
            return "Right is true";
        };
        return "Both are false";
    };
};
var main = Effect_Console.log(f(new Data_Maybe.Just(true))(new Data_Either.Right(true)));
module.exports = {
    f: f,
    main: main
};

一方で、F#ではだいたい同じ処理をこのように書くことができます。

// http://www.fssnip.net/ji/title/Either-in-F
[<AutoOpen>]
module Either

type Either<'a, 'b> =
    | Left of 'a
    | Right of 'b

type either<'a, 'b> =
    Either<'a, 'b> // lower-case alias like option

let isLeft = function
  | Left _ -> true
  | _      -> false

let isRight = function
  | Right _ -> true
  | _      -> false

let f (fa: Option<bool>) (fb: Either<bool, bool>) =
  match fa, fb with
  | (Some true), (Right true) -> "Both true"
  | (Some true), (Left _) -> "Some is true"
  | None, (Right true) -> "Right is true"
  | _, _ -> "Both are false"

f (Some true) (Right true) |> printfn "%s"

Fable 用の Webpack の Loader である fable-loader などを使うことで、デフォルトの設定では次のような ESModules 形式の JS のコードに変換されます。

import { declare, Union } from "fable-library/Types.js";
import { union } from "fable-library/Reflection.js";
import { toConsole, printf } from "fable-library/String.js";
export const Either$00602 = declare(function Either_Either(
  tag,
  name,
  ...fields
) {
  Union.call(this, tag, name, ...fields);
},
Union);
export function Either$00602$reflection($gen$$1, $gen$$2) {
  return union("Either.Either`2", [$gen$$1, $gen$$2], Either$00602, () => [
    ["Left", [$gen$$1]],
    ["Right", [$gen$$2]]
  ]);
}
export function isLeft(_arg1) {
  if (_arg1.tag === 0) {
    return true;
  } else {
    return false;
  }
}
export function isRight(_arg1$$1) {
  if (_arg1$$1.tag === 1) {
    return true;
  } else {
    return false;
  }
}
export function f(fa, fb) {
  var $target$$7;

  if (fa == null) {
    if (fb.tag === 1) {
      if (fb.fields[0]) {
        $target$$7 = 2;
      } else {
        $target$$7 = 3;
      }
    } else {
      $target$$7 = 3;
    }
  } else if (fa) {
    if (fb.tag === 0) {
      $target$$7 = 1;
    } else if (fb.fields[0]) {
      $target$$7 = 0;
    } else {
      $target$$7 = 3;
    }
  } else {
    $target$$7 = 3;
  }

  switch ($target$$7) {
    case 0: {
      return "Both true";
    }

    case 1: {
      return "Some is true";
    }

    case 2: {
      return "Right is true";
    }

    case 3: {
      return "Both are false";
    }
  }
}

(function() {
  const arg10 = f(true, new Either$00602(1, "Right", true));
  const clo1 = toConsole(printf("%s"));
  clo1(arg10);
})();

SPA 開発のように生成される JS をブラウザで動かしたい場合、Tree Shaking ができるなどの理由から ESModules 形式で出力したくなるケースがあります。

PureScript は現状 CommonJS Modules 以外のモジュールシステムをターゲットにできず、ES modules · Issue #3613 · purescript/purescriptで今後の対応が議論されています。

また、Fable は仕組みとして F#のコード -> F# AST -> Babel AST -> JS のコード の順に変換していくため、Bundler などを使う際に Babel のオプションを噛ませることも可能です。例えば Webpack(Fable/src/fable-loader at master · fable-compiler/Fable) で Babel の @babel/preset-env を使う場合は、次のように webpack.config.js を記述します。

const path = require("path");

module.exports = {
  mode: "development",
  entry: "./src/App.fsproj",
  devtool: "  source-map",
  output: {
    path: path.join(__dirname, "./public"),
    filename: "bundle.js"
  },
  devServer: {
    publicPath: "/",
    contentBase: "./public",
    port: 8080
  },
  module: {
    rules: [
      {
        test: /\.fs(x|proj)?$/,
        exclude: "/node_modules/",
        use: {
          loader: "fable-loader",
          options: {
            babel: {
              presets: [
                [
                  "@babel/preset-env",
                  {
                    modules: false
                  }
                ]
              ]
            }
          }
        }
      }
    ]
  },
  optimization: {
    usedExports: true
  }
};

Source Map や Tree Shaking を有効にするための設定が一部含まれています。fableconfig.json (TS の tsconfig.jsonに相当) にもそういった設定を記述する必要があります。

{
  "sourceMaps": true,
  "targets": {
    "production": {
      "sourceMaps": false
    }
  },
  "module": "es2015",
  "ecma": "es2015"
}

これで TypeScript を使うときと変わらず、Tree Shaking や sourcemap が利用できます。素晴らしいですね。

2.4 Language Server の実装やいい感じのビルドツールが存在する

F#にはどちらもあります。ビルドツールは dotnet というものがあり、Language Server の実装は Vim や VSCode の拡張から使われています。ナイスなパッケージマネージャの paket というものもあります。詳しくは@cannorinさんの「F# を知ってほしい」をご覧ください。

  • Mono と同じく, .NET Core は Windows, OS X, Linux のどのプラットフォームでも全く同じ開発・実行環境を使うことができます.
  • .NET Core には dotnet という CLI ツール が同梱されており, Rust における cargo コマンドと同様の立ち位置・同等の強力な機能を備えています.
    • SDK 自体に同梱されているので何もしなくても使えますし, コンパイラを作っているのと同じところが作っているので余計な互換性問題を考えなくて済むのも利点です(cargo と同じように).

Q. Visual Studio Code がないと書けないのでは?

A. Vim プラグイン や Emacs mode があり, IntelliSense 補完やオンザフライでのシンタックス/コンパイルエラーチェック, 定義されているソースへのジャンプなどを使うことができます.

また, Ionide という VSCode 用の F# 拡張機能があり, こちらでは上に加えて CodeLens での型シグネチャ表示やマウスオーバーでの型表示, GUI でのデバッグなどもすることができます.

なお搭載されている補完エンジン自体はすべて共通のもので, Visual Studio のものよりは賢くないですが十分便利です.

まとめ

  • Fable という F#のコードを Babel compatible な AST に変換するツールがある
    • Babel を使うことで TypeScript 同様、target などを自由に指定できる
    • バンドラを使う際にはTree Shakingやsourcemapを利用できる
  • Fable によって、F#で Web フロントエンド開発をすることができる

Top comments (0)