TypeScript人のためのPureScript入門
この記事はPureScript Advent Calendar 2022 12日目の記事です
この記事では、TypeScript習得者がPureScriptを勉強するときに躓きがちな概念について、TypeScriptを使った解釈を紹介します。
なお、詳細な文法の解説はしません。PureScriptの資料は少ないので、基礎から入門したい方は代わりにHaskellに入門することをお勧めします。HaskellとPureScriptは99%同じです。
免責
筆者は専門家ではないので間違った説明をしている場合があります。
関数呼び出し
PureScriptでの関数呼び出しにはかっこを付けません。TypeScriptでfunc(a, b, c)
ならPureScriptではfunc a b c
のように呼び出します。
TypeScriptでの関数呼び出し
console.log("Hello PureScript!")
PureScriptでの関数呼び出し
log "Hello PureScript!"
カリー化
カリー化とは以下のような関数を
const add = (a, b) => a + b;
add(5, 7) // 12
const add = (a) => (b) => a + b;
add(5)(7) // 12
のように書き換えることです。カリー化すると関数の部分適用を簡単に書けます。
例えば、カリー化されていない関数に最初の引数だけ適用しようとすると
const add = (a, b) => a + b;
const add5 = (b) => add(5, b);
add5(7) // 12
このadd5
関数のように新しい関数を手書きする必要があります。しかし、関数がカリー化されていた場合、
const add = (a) => (b) => a + b;
const add5 = add(5);
add5(7) // 12
のように、関数定義の繰り返しを避けることができます。
これはReactでも便利で、
const add = (a, b) => a + b;
const add_a = useCallback((b) => add(a, b), [a]);
と書いていたものが、
const add = (a) => (b) => a + b;
const add_a = useMemo(() => add(a), [a]);
というようにすっきりかけます。
PureScriptでの関数定義
先ほどのadd関数はPureScriptで以下のように定義できます。
add a b = a + b
add
は関数の名前で、そのあとにaとbという引数を続けて書きます。defとかconstとかfnとかいう修飾子は要りません。
PureScriptの関数はデフォルトでカリー化されていて、部分適用するには
add5 = add 5
とします。これはadd5 b = add 5 b
と書いたのと同じですが、より簡潔です。
型宣言
型宣言は::
の後に続けます。先ほどのadd関数は以下のように型宣言出来ます。
add :: Int -> Int -> Int
add a b = a + b
引数を->
でつなげて、最後に返り値を書きます。引数を->
でつなげるのは関数がカリー化されているからです。例えば最初の引数だけ適用すると返り値はInt -> Int
の関数になります。
代数的データ型
TypeScriptで以下のような型をかいたことがあると思います。
type State =
| { type: "loading" }
| { type: "resolve"; data: string }
| { type: "reject"; error: string };
PureScriptだとこうなります。
data State = Loading | Resolve String | Reject String
このコードにおいて、State
は型を表し、Loading, Resolve, Reject
はState
という型の値を作る関数を表します。State
のことを「データ型」、Loading, Resolve, Reject
は「値コンストラクタ」と呼びます。
TypeScriptで同じ意味のコードを書くならこうなります。
const Loading: State = { type: "loading" };
const Resolve = (data: string): State => ({ type: "resolve", data });
const Reject = (error: string): State => ({ type: "reject", error });
パターンマッチ
PureScriptにはパターンマッチという機能があります。
次のようなTypeScriptのコードがあったとしましょう。
const omikuzi = (n: number, name: string): string => {
if (n === 7) return name + "さん、大当たり!";
else if (n === 6) return "惜しい";
else return "はずれ";
};
omikuzi(1, "arark"); // "はずれ"
omikuzi(7, "arark"); // "ararkさん、大当たり!"
omikuzi(6, "arark"); // "惜しい"
引数が7だったら"${名前}、大当たり!"
を返し、6だったら"惜しい"
を返します。どれにも当てはまらない場合は"はずれ"
を返します。
これをPureScriptで書き直すとこうなります。
omikuzi :: Int -> String -> String
omikuzi 7 name = name <> "さん、大当たり!"
omikuzi 6 _ = "惜しい"
omikuzi _ _ = "はずれ"
omikuzi 7 "arark" -- "ararkさん、大当たり!"
omikuzi 6 "arark" -- "惜しい"
omikuzi 1 "arark" -- "はずれ"
(--
以降はコメントです。また、<>
は文字列の結合演算子です。)
1行目でomikuzi
の型宣言をしています。引数としてIntとStringを受け取って、おみくじの結果をStringで返します。
2行目から4行目がパターンマッチの本体です。 omikuzi 7 name = ...
の行は、最初の引数が7のときにマッチし、実行されます。このとき2つ目の引数をname
という名前に束縛(代入)します。
omikuzi 6 _ = ...
は最初の引数が6のときにマッチし、実行されます。このとき、2つ目の引数は使わないので、アンダーバーで「どんな値が来てもよいこと」を明示します。アンダーバーは全てにマッチするPureScriptの予約語です。
omikuzi _ _ = ...
は引数がどんな値でも実行される行です。もう一度言いますが、アンダーバーは予約語であって変数名ではないので2回使っても問題ありません。
この例だけでもパターンマッチを使えば条件分岐が綺麗に書けることがわかってもらえるかと思います。
代数的データ型でパターンマッチ
パターンマッチはデータ型と一緒に使うとより便利になります。
PureScriptでStateデータ型とその値コンストラクタを作れることがわかりました。もう一度定義を見てみましょう。
data State = Loading | Resolve String | Reject String
Resolve
はPureScript的に書くと
Resolve :: String -> State
TypeScript的に書くと
function Resolve(s: string): State;
という型を持つ関数(値コンストラクタ)でした。Resolveの動作は「文字列をState型に包む」、と解釈出来ます。
ではこのState
型の値を、どのようにコード中で使えばよいでしょうか。
具体的には
- Stateを作った値コンストラクタが
Loading, Resolve, Reject
のそれぞれの場合で動作を分岐させる。 -
Resolve
かReject
のときに、包まれた文字列を取り出す。
という動作をさせたいです。
この動作をするTypeScriptのコードは次のようになります。
const unwrapState = (state: State): string => {
if (state.type === "loading") return "読み込み中です";
else if (state.type === "resolve")
return "取得したデータは" + state.data + "です";
else if (state.type === "reject")
return state.error + "というエラーが発生しました。";
throw new Error("予期しないパターンです");
};
unwrapState({ type: "loading" }); // "読み込み中です"
unwrapState({ type: "resolve", data: "12345" }); // "取得したデータは12345です"
unwrapState({ type: "reject", error: "500 internal server error" }); // 500 internal server errorというエラーが発生しました。
stateのtypeによって条件分岐をし、ResolveかRejectであればStateに包まれた文字列を抜き出して処理に使っています。
パターンマッチを使えばこの動作を綺麗に書くことが出来ます。
unwrapState :: State -> String
unwrapState Loading = "読み込み中です"
unwrapState (Resolve d) = "取得したデータは" <> d <> "です"
unwrapState (Reject e) = e <> "というエラーが発生しました。"
unwrapState (Loading) -- "読み込み中です"
unwrapState (Resolve "12345") -- "取得したデータは12345です"
1行目で型宣言をしています。Typescriptのときと同じ型です。
2~4行目でパターンマッチを使っています。
unwrapState Loading = ...
の行は最初の引数がLoading値コンストラクタによって作られている場合にマッチします。
unwrapState (Resolve d) = ...
はResolve値コンストラクタにマッチします。このときResolveが包んでいる文字列をdという名前に束縛(代入)します。unwrapState (Reject e) = ...
も同じで、エラーの内容をeという名前に束縛します。
どのパターンにもマッチしない場合は実行時エラーが発生します。
最初は慣れないかもしれませんが、パターンマッチを使えばデータ型の条件分岐をすっきりと書くことができます。
型コンストラクタ
さて、次はStateデータ型をもっと汎用的にすることを考えます。前章までのStateデータ型は取得したデータとエラーの内容が文字列に限定されていました。
Typescriptで書くとこうなります。
type State2<D, E> =
| { type: "loading" }
| { type: "resolve"; data: D }
| { type: "reject"; error: E };
よくあるジェネリクスですね。
PureScriptで同じことをすると、
data State2 d e = Loading | Resolve d | Reject e
になります。State2 d e
はdとeという、型の引数(型引数)を受け取り具体的な型を返す「型コンストラクタ」です。型コンストラクタはTSのジェネリクスと言い換えてもいいかもしれません。
パターンマッチは似たような記法で書けます。使い方が少し複雑になりますが、ここでは触れません。詳細はこちら
unwrapState :: forall d e. Show d => Show e => State2 d e -> String
unwrapState Loading = "読み込み中です"
unwrapState (Resolve d) = "取得したデータは" <> show d <> "です"
unwrapState (Reject e) = show e <> "というエラーが発生しました。"
型クラス
型クラスというのは要はインターフェースです。PureScriptにはEq
という型クラスがあります。これは「等値判定可能なデータ型」を表します。
PureScriptでは型クラスは以下のように書きます。
class Eq a where
eq :: a -> a -> Boolean
同じことをTypeScriptで書くと、
interface Eq<a> {
eq: (arg1: a) => (arg2: a) => boolean;
}
になります。
このEq型クラスをPureScriptで使うと以下のようになります。
isEq :: forall a. Eq a => a -> a -> Boolean
isEq a b = eq a b
この型宣言は「具体的に指定しないが、Eq型クラスの一員であるa
というデータ型の引数を2つ受け取ってBooleanを返す」という意味です。
これをTypeScriptに翻訳すると、
const isEq =
<A>(Eq: Eq<A>) =>
(a: A) =>
(b: A) =>
Eq.eq(a)(b);
になります。
ちなみに、PureScriptにも==
は存在します。JavaScriptでは==
は言語仕様で決まった演算子ですが、PureScriptにとって==
はただのeq
関数のエイリアスです。
型クラスのサブクラス化
次に値の大小関係を判定可能なデータ型を「Ord型クラス」で表すことを考えます。大小関係が判定可能ならば等値関係も判定可能ですから、Eq型クラスを利用してOrd型クラスを定義する、すなわちOrd
をEq
のサブクラスにすると便利です。
PureScriptではOrd型クラスを次のように書きます。
data Ordering = LT | GT | EQ
class Eq a <= Ord a where
compare :: a -> a -> Ordering
これをTypeScriptに翻訳するとこうなります。
type Ordering = LT | GT | EQ;
type LT = -1;
type GT = 1;
type EQ = 0;
interface Ord<a> extends Eq<a> {
compare: (arg1: a) => (arg2: a) => Ordering;
}
Ord
の使用方法はEq
のときと同じです。
データ型のインスタンス化
先ほど定義したStateデータ型も、等値判定が出来ると便利そうです。2つのState型の値が以下のときに等値とするのが自然でしょう。
- 両方Loadingのとき
- 両方Resolveかつ、中身のデータが同じとき
- 両方Rejectかつ、エラーの内容が同じとき
Stateデータ型を再掲します(汎用的ではないほうのStateです)。
data State = Loading | Resolve String | Reject String
このStateデータ型がEq型クラスの一員である(すなわち等値判定できる)と宣言をすることを、「StateをEqのインスタンスにする」といいます。
実際にやってみましょう。
instance eqState :: Eq State where
eq Loading Loading = true
eq (Resolve d1) (Resolve d2) = d1 == d2
eq (Reject e1) (Reject e2) = e1 == e2
eq _ _ = false
1行目のeqState
は人間のためにEq State
に付けた名前です。
2~5行目がインスタンス化の本体です。といってもパターンマッチを使っているだけなので難しいことはありません。先ほど説明したルールをコードで表しているだけです。
これを実際に使うと、
Loading == Loading -- true
Loading == Resolve "123" -- false
Resolve "123" == Resolve "123" -- true
というように、期待する動作をしていることがわかります。
ちなみに、インスタンス宣言をTypeScriptで書き直すとこうなります。
interface Eq<a> {
eq: (arg1: a) => (arg2: a) => boolean;
}
type State =
| { type: "loading" }
| { type: "resolve"; data: string }
| { type: "reject"; error: string };
const eqState: Eq<State> = {
eq: (s1) => (s2) => {
if (s1.type === "loading" && s2.type === "loading") return true;
else if (s1.type === "resolve" && s2.type === "resolve")
return s1.data === s2.data;
else if (s1.type === "reject" && s2.type === "reject")
return s1.error === s2.error;
else return false;
},
};
みづらいですね。パターンマッチと代数的データ型の便利さがわかると思います。
なお、ここで文字列の比較に===
演算子をつかいましたが、厳密にPureScriptを翻訳するなら==
はEq<string>.eq
メソッドであるべきです。そこまで書くとさすがにわかりづらいので省略しました。
まとめ
この記事ではカリー化、代数的データ型、パターンマッチ、型クラス、インスタンス化などの基本的な機能についてみてきました。紹介した解釈はPureScriptでなくても活かせると思います。また、関数型言語といえばモナドやファンクターという用語を聞いたことがある人もいるかもしれませんが、これらも結局は型クラスです。興味がある人は調べてみてください。
PureScriptは素晴らしい言語なので、人口が増えると嬉しいです。
宣伝
Discussion