# 函數式程式設計在網頁前端開發的入門應用 講稿 這個議程會使用 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. > > &mdash; 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/)