# 函數式程式設計在網頁前端開發的入門應用 講稿
這個議程會使用 TypeScript 介紹 functional programming 中的常見概念與在 web front-end 中的應用。選用 TypeScript 是因為它的 type system 雖然不夠好,但已經足夠讓我們以 type 為出發點思考問題。同時它也有 JavaScript 的靈活性。
## Sum Type 和 Product Type
在像 Haskell 或是 OCaml 這種 ML 系的 FP 語言中,常會看到由 sum type 和 product type 組合而成的 algebraic data type ,例如:
```haskell
data List a = Nil | Cons a (List a)
```
在 JS 裡我們沒有 sum type 和 product type ,但我們可以模擬一下,並用 TypeScript 描述它們:
```typescript
interface Nil {
type: 'nil'
}
interface Cons<T> {
type: 'cons'
head: T
tail: List<T>
}
type List<T> = Nil | Cons<T>
```
此時我們用 TypeScript 的 discriminated union 來替代 sum type ,當用到這種資料時,可以把 `switch` 當成陽春版的 pattern matching 用。同時以 object 替代 product type 。
有了陽春版的 sum type 和 product type ,現在我們可以開始建立抽象層。
## 靠抽象層切割問題
如果大家有看過 SICP 影片的話,可以注意到在構造有理數時,課程慢慢變得有趣了起來。
> We're going to solve that problem by using this incredbly powerful design strategy that you already seen us use over and over.
>
> And that's the strategy of wishful thinking.
>
> — Harold Abelson
```typescript
export type Rat = [number, number]
export declare makeRat = (numer, denom) => Rat
export declare numer = (rat: Rat) => number
export declare denom = (rat: Rat) => number
```
因為 JS 現在有了 module system ,我們也可以把同樣的技巧用在 JS 上。
## `ToDoItem`
今天我們的目標是做一個 ToDo app ,從最底部開始思考,我們的 ToDo item 該長什麼樣子呢?
```typescript
export interface ToDoItem {
id: string
title: string
done: boolean
}
```
接下來我們要能創造、存取、更新這個 ToDo :
```typescript
export const create = (id: string, title: string, done: boolean): ToDoItem =>
({ id, title, done })
export const id = (item: ToDoItem): string => item.id
export const title = (item: ToDoItem): string => item.title
export const done = (item: ToDoItem): boolean => item.done
export const update = (done: boolean) => (item: ToDoItem) => ({
id: item.id,
title: item.title,
done,
})
```
注意在這裡我手動 curry 了 `update` 函數,這樣做會讓在保持每個 ToDo 不可變的同時簡化我們的程式碼。
根據 SICP 的說法,只要 module 的介面沒變,我們完全可以用另外一種方式實現 `ToDoItem` :
```typescript
export type ToDoItem = [string, string, boolean]
export const create = (id: string, title: string, done: boolean): ToDoItem =>
[id, title, done]
export const id = ([id]: ToDoItem): string => id
export const title = ([, title]: ToDoItem): string => title
export const done = ([,, done]: ToDoItem): boolean => done
export const update = (done: boolean) => ([id, title]: ToDoItem) =>
[id, title, done]
```
同時也會發現,我們能把 tuple 當成 product type 用。
## `ToDoList`
做完 `ToDoItem` ,再用一樣的思路做 `ToDoList` :
```typescript
import * as TDI from './ToDoItem'
export interface ToDoList {
key: number
todos: TDI.ToDoItem[]
}
export const create = (): ToDoList => ({
key: 0,
todos: []
})
export const append = (title: string) => (list: ToDoList): ToDoList => {
const { key: prev, todos } = list
const key = prev + 1
const item = TDI.create(`${key}`, title, false)
return {
key,
todos: [...todos, item]
}
}
export const updateById = (id: string, done: boolean) => (list: ToDoList): ToDoList => {
const { key, todos } = list
const i = todos.findIndex((item) => item.id === id)
if (i === -1) return list
return {
key,
todos: [
...todos.slice(0, i),
TDI.update(done)(todos[i]),
...todos.slice(i + 1)
]
}
}
export const removeById = (id: string) => (list: ToDoList): ToDoList => {
const { key, todos } = list
const i = todos.findIndex((item) => item.id === id)
if (i === -1) return list
return {
key,
todos: [
...todos.slice(0, i),
...todos.slice(i + 1)
]
}
}
```
## 在不同的語境中工作
### 和 OOP 合作
如果不喜歡一直傳遞最新的物件,我們也可以把這種風格的 `ToDoList` 包裝成一個 OOP `class` :
```typescript
import * as TDL from './ToDoList'
export default class ToDoList {
private _toDoList: TDL.ToDoList
constructor() {
this._toDoList = TDL.create()
}
append(title: string) {
this._toDoList = TDL.append(title)(this._toDoList)
}
updateById(id: string, done: boolean) {
this._toDoList = TDL.updateById(id, done)(this._toDoList)
}
}
```
但為什麼那麼堅持要手動 curry ,並把要修改的物件,放在最後一個參數呢?
因為這樣做的話,要是哪一天 `ToDoList` 被放到 `Promise` 中,我們還是可以這樣操作它:
```javascript
Promise.resolve(toDoList)
.then(TDL.append('foobar'))
.then(TDL.updateById('0', true))
```
要是我們把 `ToDoList` 放在 React state 裡面,也可以直接操作它:
```typescript
const Comp = () => {
const [toDoList, setToDoList] = useState(TDL.create())
const handleToDoUpdate = (id: string, done: boolean) => {
setToDoList(TDL.updateById(id, done))
}
}
```
這讓我本來很期待 `|>` pipeline operator :
```javascript
toDoList
|> TDL.append('foobar')
|> TDL.updateById('0', true)
```
那時候還是所謂的 F# style pipeline operator (為什麼不叫 OCaml style 啊,兒子比老子有名,這樣對嗎?)。但現在的 Hack style pipeline operator 寫起來就比較囉唆了:
```javascript
toDoList
|> TDL.append('foobar')(%)
|> TDL.updateById('0', true)(%)
```
我不喜歡 Hack pipeline ,但是 proposal 裡提到:
> **Both** Hack pipes and F# pipes respectively impose a small **syntax tax** on different expressions:
> **Hack pipes** slightly tax only **unary function calls**, and
> **F# pipes** slightly tax **all expressions except** unary function calls.
可見 JS 為了過去的舊設計,選擇了痛苦比較少的道路。
> But with F# pipes, `value |> someFunction + 1` is **still valid syntax** – it’ll just **fail late** at **runtime**, because `someFunction + 1` isn’t callable
但 proposal 內才剛說完要考慮到未來會有 operator overloading ,就舉例子說 `someFunction + 1` 不會是個函數還滿怪的。在 FP 的世界,你當然可以讓兩個函數被 `+` 起來,而且結果是個新的函數。
反而後面提到 F# style pipeline 會帶來實作上的困難還比較有說服力。
大家可以自己讀一下 [tc39/proposal-pipeline-operator](https://github.com/tc39/proposal-pipeline-operator/) ,看看你喜歡哪一種 pipeline ?
## Redux
Redux action 大概長這樣:
```typescript
interface Action<T> {
type: 'redux/someAction'
payload: T
}
```
這怎麼看都是個 sum type 吧?事實上因為 Redux 一開始是「借鑑」 Elm 的, action 長得像 ML 語言的 sum type 也是情理之中。既然是個 sum type ,我們沒道理只在 payload 裡面放 primitive types 。
我們做個裝著 action 的 action !
```typescript
interface RequestAsyncAction<C> {
type: 'async/request'
context: C
}
interface SuccessAsyncAction<C, T> {
type: 'async/success'
context: C
payload: T
}
interface FailureAsyncAction<C> {
type: 'async/failure'
context: C
error: Error
}
export type AsyncAction<C, T> =
| RequestAsyncAction<C>
| SuccessAsyncAction<C, T>
| FailureAsyncAction<C>
export const request = <C>(context: C): RequestAsyncAction<C> => ({
type: 'async/request',
context,
})
export const success = <C, T>(context: C, payload: T): SuccessAsyncAction<C, T> => ({
type: 'async/success',
context,
payload
})
export const failure = <C>(context: C, error: Error): FailureAsyncAction<C> => ({
type: 'async/failure',
context,
error
})
```
於是使用 `redux-thunk` 時,就能這樣合成我們的 thunk actions :
```typescript
import { Dispatch } from 'redux'
import { ToDoService } from '../services'
import * as ToDoListAction from './ToDoListAction'
import * as AsyncAction from './AsyncAction'
type AppendDispatch = Dispatch<AsyncAction.AsyncAction<string, ToDoListAction.Action>>
export const append = (title: string) => async (dispatch: AppendDispatch) => {
dispatch(AsyncAction.request(title))
try {
const item = await ToDoService.create(title)
dispatch(AsyncAction.success(title, ToDoListAction.append(item)))
} catch (err: any) {
dispatch(AsyncAction.failure(title, err))
}
}
type UpdateDispatch = Dispatch<AsyncAction.AsyncAction<[string, boolean], ToDoListAction.Action>>
export const updateById = (id: string, done: boolean) => async (dispatch: UpdateDispatch) => {
const context: [string, boolean] = [id, done]
dispatch(AsyncAction.request(context))
try {
const item = await ToDoService.update(id, done)
dispatch(AsyncAction.success(context, ToDoListAction.updateById(id, item)))
} catch (err: any) {
dispatch(AsyncAction.failure(context, err))
}
}
type RemoveDispatch = Dispatch<AsyncAction.AsyncAction<string, ToDoListAction.Action>>
export const removeById = (id: string) => async (dispatch: RemoveDispatch) => {
dispatch(AsyncAction.request(id))
try {
await ToDoService.remove(id)
dispatch(AsyncAction.success(id, ToDoListAction.removeById(id)))
} catch (err: any) {
dispatch(AsyncAction.failure(id, err))
}
}
```
這樣在 root reducer 中,我們可以先處理外層 action ,再把裡面的 action 交給其他 reducers :
```typescript
const reducer = (state = initialState, action: Action): State => {
switch (action.type) {
case 'async/request': {
return {
...state,
isLoading: true,
}
}
case 'async/success': {
const act = action.payload as ToDoListAction.Action
return {
isLoading: false,
list: toDoListReducer(state.list, act)
}
}
case 'async/failure': {
return {
...state,
isLoading: false,
}
}
default:
return state
}
}
```
內層 reducer 現在只要更新收到的 todo 即可:
```typescript
const reducer = (state = initialState, action: Action): TDL.ToDoList => {
switch (action.type) {
case 'todos/append': {
return TDL.append(action.payload)(state)
}
case 'todos/updateById': {
const [id, item] = action.payload
return TDL.updateById(id, item)(state)
}
case 'todos/removeById': {
return TDL.removeById(action.payload)(state)
}
default:
return state
}
}
```
這樣相當於手動 compose reducers 。
當然現在不用這樣搞, Redux Toolkit 用一種比較「好懂」的方式處理這件事, `createAsyncThunk` 會自動產生不同狀態的 action types ,像是 `some/action/pending`, `some/action/fulfilled`, `some/action/rejected` 。
## 網頁前端裡的 CPS 變換
在介紹 CPS 變換之前,要先講一下什麼是 CPS ,也就是 continuation-passing style 。在 FP 的世界, continuation-passing style 指的是你的函數不回傳東西,而是把計算結果交給下一個函數當參數。
例如一個相加兩數的函數:
```typescript
function add(a: number, b: number): number {
return a + b
}
console.log(add(1, 2))
```
變成:
```typescript
function add(a: number, b: number, cb: (x: number) => void): void {
cb(a + b)
}
add(1, 2, (c) => console.log(c))
```
常寫 JS 的人大概已經聞到了熟悉的味道,是的, Node.js API 那些需要傳 callback 的函數就是 CPS 。
這邊大家可以注意,一般的程式能很簡單地轉成 CPS 程式,但 CPS 程式就沒法很簡單地轉成一般的程式。畢竟你不知道 callback 函數什麼時候會被呼叫。為此先有人引入了 deferred 物件,後來又誕生的 Promise/A+ 標準。
於是我們可以把 `add` 寫成:
```typescript
function add(a: number, b: number): Promise<number> {
return Promise.resolve(a + b)
}
add(1, 2).then(console.log)
```
在這裡我們不關心 `add` 是直接幫我們算出 `a + b` ,還是呼叫躲在 AWS 上的 API 幫我們算 `a + b` ,只要知道我們等一下會拿到一個 `number` 就好了。
但這個「等一下」仍然是個麻煩,因為不知道會等多久,我們沒法把 `Promise<number>` 直接拆成 `number` 。
在 MSFT 對 JS 的大力貢獻下, JS 世界有了 C# 那樣的 `async/await` 語法糖,現在我們可以這樣用:
```typescript
const c = await add(1, 2)
console.log(c)
```
從 `add(1, 2).then(console.log)` 到 `const c = await add(1, 2)` 就是一種 CPS 變換( CPS transformation )。
但 `async/await` 只能用在 `Promise` 上,卻不能用在其他類似的結構上。好在隨著 React 和 Vue 等前端 library/framework 決定他們「要搶著做程式語言該做的事」,我們現在有很多手段可以在不同的結構上做到 CPS 變換。
例如我們可以用 React Hooks 來 resolve Promise :
```javascript
import { useState, useEffect } from 'react';
function isThenable(p) {
return p.then && typeof p.then === 'function';
}
function usePromise(promise) {
// TODO: make inner isPending a counter
const [[value, error, pending], setResult] = useState([
undefined,
undefined,
0,
]);
useEffect(() => {
if (!promise) {
setResult(([, , pending]) => [undefined, undefined, pending]);
return;
}
if (!isThenable(promise)) {
setResult(([, , pending]) => [promise, undefined, pending]);
return;
}
setResult(([value, error, pending]) => [value, error, pending + 1]);
promise.then(
x => setResult(([, , pending]) => [x, undefined, pending - 1]),
e => setResult(([, , pending]) => [undefined, e, pending - 1])
);
}, [promise]);
return [value, error, pending > 0];
}
export default usePromise;
```
就能在 React 元件內這樣寫:
```javascript
const Comp = () => {
const p = useMemo(() => add(1, 2), [])
const c = usePromise(p)
return (
<span>1 + 2 = {c}</span>
)
}
```
我們甚至可以用 React Hooks 拆解 RxJS 的 Observable :
```javascript
import { useState, useMemo, useEffect } from 'react';
import { BehaviorSubject } from 'rxjs';
export default function useObservable(input$, initialState) {
const [state, setState] = useState(initialState);
const state$ = useMemo(() => new BehaviorSubject(initialState), []);
useEffect(() => {
const sub = state$.subscribe(x => setState(x));
return sub.unsubscribe.bind(sub);
}, [state$]);
useEffect(() => {
const sub = input$.subscribe(state$.next.bind(state$));
return sub.unsubscribe.bind(sub);
}, [input$]);
return state;
}
```
但大概是因為 React Hooks 是個「半殘的抽象工具」,幾乎沒有人這樣實作 `usePromise` 和 `useObservable` 。倒是 Vue3 上有個 `vue-compose-promise` 提供了類似的介面:
```vue
<template>
<div>
<span> Is the promise still pending: {{ usersPromise.isPending }} </span>
<span> Is the 200ms delay over: {{ usersPromise.isDelayOver }} </span>
<span>
Last successfully resolved data from the promise: {{ usersPromise.data }}
</span>
<span> Error if current promise failed: {{ usersPromise.error }} </span>
</div>
</template>
<script>
import { createComponent } from '@vue/composition-api'
import { usePromise } from 'vue-compose-promise'
export default createComponent({
setup() {
const promised = usePromise({ pendingDelay: 200, promise: fetchUsers() })
return {
usersPromise: promised.state,
fetchUsers() {
promised.state.promise = fetchUsers()
},
}
},
})
</script>
```
到這裡,如果你有注意到我在做什麼的話,那當我用 React Hooks 對 array 做 CPS 變換時,你也不會驚訝了:
```javascript
import { useState, useEffect } from 'react';
export default function useArray(xs) {
const [s, set] = useState(xs[0]);
useEffect(() => {
for (let v of xs) {
setImmediate(set, v);
}
}, [xs]);
return s;
}
```
### 網頁前端的 Lifting
本來這邊想講 `Observable` 的,但因為太麻煩了所以講個 `Promise` 就好。
假設我們今天想弄個晚一點才會開始跑的 promise ,就叫它 `LazyPromise` 好了。我們一樣想用 `then` 把它們串起來,可以這樣做:
```typescript
export type LazyPromise<T> = () => Promise<T>
export const of = <T>(x: T) =>
() => Promise.resolve(x)
export const then = <T, U>(f: (x: T) => LazyPromise<U>) => (lp: LazyPromise<T>): LazyPromise<U> =>
() => lp().then((x: T) => f(x)())
```
配上 OCaml style pipeline operator ,我們可以這樣用:
```typescript
import * as LP from './LazyPromise'
const one = LP.of(1)
const two = LP.of(2)
describe('LazyPromise', () => {
it('should chain lazy promises', async () => {
const run =
one |> LP.then((x) =>
two |> LP.then((y) =>
LP.of(x + y)
))
expect(await run()).toBe(3)
})
})
```
大概就這樣。
## further readings
- [SICP](https://mitpress.mit.edu/sites/default/files/sicp/full-text/book/book.html)
- [Domain Modeling Made Functional](https://pragprog.com/titles/swdddf/domain-modeling-made-functional/)
- [Comprehending Monads](https://ncatlab.org/nlab/files/WadlerMonads.pdf)
- [How to make ad-hoc polymorphism less ad hoc](https://dl.acm.org/doi/10.1145/75277.75283)
- [fantasy-land](https://github.com/fantasyland/fantasy-land)
- [fp-ts](https://gcanti.github.io/fp-ts/)