iT邦幫忙

2025 iThome 鐵人賽

DAY 20
0

https://ithelp.ithome.com.tw/upload/images/20251004/20168201g58byMsOSV.png

前言

昨天的文章中,我們認識了如何用 IO 這容器延後副作用的執行,掌握了 IO 的核心思想後,我們就可以將同樣的原則應用到一個更複雜、更常見的領域:非同步操作。Task 就是 IO 在非同步世界的巒生兄弟,它可以為我們解決 JavaScript 中麻煩的非同步問題,今天就來看看 Task 是什麼吧~

為什麼要有 Task?

在現代 JavaScript 中,非同步操作無所不在,傳統上我們用回呼函式 (callback) 來處理,但很快就陷入「callback hell」的維護地獄,而為了解決這問題,Promise 就出現了! 使用 Promise 可以解決 callback hell 的問題,但 Promise 有一個缺點:Promise 是急切的 (Eager Evaluation)。

一旦建立 Promise,內部的非同步操作就會立即啟動,不管你是否真的需要,或何時才需要它。這在 FP 的觀點下是一種失去控制的狀態——我們無法先「描述」一個 API 呼叫,等到準備好再啟動;就像火箭圖紙一畫好,引擎就自動點火發射,讓我們失去將它與其他流程組合的機會。

而 Task 就是為了解決這個問題,可以將 Task 視為一種「純函式版的 Promise」,也可稱作 Lazy Promise:像 Promise 一樣代表一個未來的結果,但不會在建立時就急著執行。我們可以拿到一個 Task,透過 map 等方式先描述並組合一系列非同步工作,直到最後主動觸發時才真的執行。這種惰性模型讓非同步流程更易於組合、避免回呼地獄,同時給我們對副作用執行時機的完整掌控。

舉例來說,new Promise(() => console.log('runs now')) 在宣告當下就會印出 'runs now';但 Task 在建立時不會立即執行,必須用 task() 或經由 match → () 觸發。

若對 Promise 和 Task 兩者的比較有興趣的,可再參考 Difference between a Promise and a TaskComparison to Promises

所以 Task 是什麼?

如果說 IO 是用來描述同步副作用的「藍圖」,那麼 Task 就是專門處理非同步副作用的「藍圖」。它和 IO 一樣是惰性的容器,但包裹的不是一個簡單的 () => value,而是一個需要時間才能完成,並且可能成功或失敗的計算。

具體來說,Task 封裝的是一個接受 (reject, resolve) 兩個回呼函式的函式,類似這樣:(reject, resolve) => void。因此,我們可以把 Task 理解為非同步版的 IO:它也是 Functor,擁有 map 等方法,但它所描述的是「未來才會得到的值或錯誤」。建立 Task 時並不會馬上執行,只有在我們明確呼叫時,Task 內的計算才會開始,並透過 rejectresolve 將結果交回,讓我們能以純函數的方式組合與控制非同步流程。

Task 使用範例:讀取檔案

因為 Task 內部實作比較複雜,我們直接用現成函式庫提供的 Task 來看看如何使用吧~

原本《mostly-adequate-guide》使用的是 Folktale 函式庫的,但因為此 repo 最近比較少維護、且已經 archived,這裡就直接使用 fp-ts 函式庫的 TaskTaskEither。Task 和 TaskEither 主要差異在於,Task 用來描述只有成功情況的非同步任務,而 TaskEither 會描述可能成功、也可能失敗的非同步任務,可以將 TaskEither 視為一個隱含結合 Either 特性的容器。

不過如果覺得加入 TypeScript 會太複雜,也可考慮用 Folktale 函式庫的 Task 先了解一下大概運作,核心概念應該差異不大。

以下我們用 fp-ts 的 TaskEither 來讀取檔案然後印出檔案中的第一行文字:
(完整可運作程式碼可參考此連結,感謝 olddunk 大大在此篇文章提供了起始專案,直接拿來用 XD)

import * as TE from 'fp-ts/TaskEither';
import * as T from 'fp-ts/Task';
import * as fs from 'fs/promises';

// 輔助函式
const compose =
  (...fns: Array<(a: any) => any>) =>
  (x: any) =>
    fns.reduceRight((v, f) => f(v), x);

const split = (sep: string) => (s: string) => s.split(sep);
const headString = (xs: string[]) => xs[0];


// ----- 主要程式 -----

/** readFile :: string -> TaskEither<Error, string> */
const readFile = (filename: string): TE.TaskEither<Error, string> =>
  TE.tryCatch(
    () => fs.readFile(filename, 'utf-8'),
    (e) => (e instanceof Error ? e : new Error(String(e)))
  );

// 這等同於 readFile('metaphor.txt').map(split('\n')).map(head)
const firstLineTE: TE.TaskEither<Error, string> = compose(
  TE.map(headString),
  TE.map(split('\n')),
  readFile
)('metaphor.txt');

console.log('TaskEither 已建立,但尚未執行');

// 執行:match 之後會得到 T.Task<void>,再呼叫它觸發
const run = compose(
  (task: T.Task<void>) => task(), // 觸發 Task
  TE.match(
    (err: Error) => {
      console.error('讀檔失敗:', err.message);
    },
    (line: string) => {
      console.log('第一行內容:', line);
    }
  )
);

run(firstLineTE);

來逐步拆解一下程式碼在做什麼吧~

整段程式建立了兩條惰性管線:

  1. 資料處理管線:把檔案內容轉第一行
const firstLineTE = compose(
  TE.map(headString),     // Right<string[]> → Right<string>
  TE.map(split('\n')),    // Right<string>   → Right<string[]>
  readFile                // string → TaskEither<Error, string>
)('metaphor.txt');        // ← 這裡只是在「描述」,還沒動硬碟
  1. 執行管線:決定「錯誤怎麼印、成功怎麼印」,並在最後一刻才觸發
const run = compose(
  (task: T.Task<void>) => task(),   // 真正觸發非同步
  TE.match(                         // 將 Left/Right 收斂成 side-effect
    (err: Error) => { console.error('讀檔失敗:', err.message); },
    (line: string) => { console.log('第一行內容:', line); }
  )
);

readFile 會回什麼?

const readFile = (filename: string): TE.TaskEither<Error, string> =>
  TE.tryCatch(
    () => fs.readFile(filename, 'utf-8'),     // ← 只在「執行時」才會呼叫
    e => e instanceof Error ? e : new Error(String(e))
  );
  • 回傳型別:TaskEither<Error, string>,想像成「尚未執行的非同步描述」,成功是 Right<string>,失敗是 Left<Error>
  • TE.tryCatch 會把 Promise 的拒絕/例外包成 Left(Error),成功包成 Right(value)

這裡只定義了讀檔的方式,還沒有真的讀檔。

TE.map 做了什麼?

TE.map 和 Either 運作概念相同,只會在 Right(成功值) 上套用轉換函式;如果是 Left(錯誤),它會原封不動地往後傳(不會觸發轉換函式)。
因此 TE.map(split('\n')) 會把 Right<string>Right<string[]>TE.map(headString) 會把 Right<string[]>Right<string>

firstLineTE 只是「描述」,還沒執行

const firstLineTE = compose(
  TE.map(headString),
  TE.map(split('\n')),
  readFile
)('metaphor.txt'); // ('metaphor.txt') 是給 readFile 的參數

console.log('TaskEither 已建立,但尚未執行');

console.log('TaskEither 已建立,但尚未執行'); 之前,都還沒去實際硬碟取出 'metaphor.txt' 這檔案。

成功路線的型別演進如下:

  1. readFile('...')TaskEither<Error, string>
  2. TE.map(split('\n'))TaskEither<Error, string[]>
  3. TE.map(headString)TaskEither<Error, string>

run 在「需要時執行」

const run = compose(
  (task: T.Task<void>) => task(),   // 這一步才真正觸發非同步
  TE.match(
    (err: Error)   => { console.error('讀檔失敗:', err.message); },
    (line: string) => { console.log('第一行內容:', line); }
  )
);

run(firstLineTE);

TE.match 會收兩個參數,分別為 onLeftonRightmatch 函式的作用是把 TaskEither<E, A> 轉成一個 Task<void>。如果後續是 Left → 執行 onLeft,如果後續是 Right → 執行 onRight

(task) => task() 才真的觸發非同步流程(真的呼叫到底層的 fs.readFile)。

實際執行流程(成功與失敗)

成功(檔案存在且非空)

  1. run(firstLineTE)
  2. TE.match 產生一個 Task<void>
  3. 呼叫該 task() → 進入 TE.tryCatchfs.readFile('metaphor.txt', 'utf-8')
  4. 讀到內容 → Right(content)
  5. TE.map(split('\n'))Right(lines: string[])
  6. TE.map(headString)Right(firstLine: string)
  7. TE.matchRight 分支執行 → console.log('第一行內容:', firstLine)

失敗(例如檔案不存在)
1–3 步同上
4. 發生錯誤 → 包成 Left(Error)
5–6. 全部 TE.map(...) 跳過(Left 不會被映射)
7. TE.match 的 Left 分支執行 → console.error('讀檔失敗:', err.message)


另外補充一下,fp-ts 的 match 與其他函式庫的 .fork 的關係,在一些函式庫(例如 Folktale)裡,Task 的執行通常透過 .fork(rejectFn, resolveFn) 來觸發:

task.fork(
  err  => console.error('失敗', err),
  data => console.log('成功', data)
);

這裡的 .fork 會同時:

  • 真正啟動惰性的 Task
  • 把錯誤丟進 rejectFn,把成功值丟進 resolveFn

在 fp-ts 中,雖然沒有 .fork 方法,但可以用 TE.match(onLeft, onRight) 搭配呼叫 () 達到相同效果:

TE.match(
  (err: Error) => console.error('失敗', err.message),
  (line: string) => console.log('成功', line)
)(firstLineTE)(); // 最後的 () 觸發 Task

兩者本質相同:都是在程式邊界,把惰性的描述真正執行起來,並分流錯誤與成功的處理邏輯。

Task 使用範例:放入純值

我們一樣可以把純值放入 Task,範例如下。

// 也可以直接建立一個純值的 Task
const numberTask: T.Task<number> = T.of(3);

// 使用 map 對容器內的值做轉換
const incremented: T.Task<number> = T.map((x: number) => x + 1)(numberTask);

// 執行
incremented().then(console.log); // 4

Task 小結

  • Task 可視為 Lazy Promise:建立當下不會執行、需要時才啟動
  • TaskEither 是 fp-ts 提供的工具,它整合了 Task 和 Either,把「失敗/成功」分流內建進非同步流程

Task 與 IO 的關係

  • Task 就像是非同步版的 IO
    • 兩者都會延遲副作用執行
    • 兩者都需要明確的執行指令才會開始運作
  • 處理非同步操作時,Task 可以 取代 IO 的角色
    • readFilegetJSON 等非同步操作,不再需要額外包 IO 容器
  • 對 Task 使用 .map() 就像在「時間膠囊」放入待辦清單,是一種「技術性拖延」
  • 何時要用 IO、何時要用 Task 呢?
    • 如果是同步的副作用 → 用 IO
    • 如果是非同步的副作用 → 用 Task

Task + IO + Either 應用範例

看越多範例越了解這些工具的運作流程! 再看一個範例吧,這範例會直接使用 fp-ts 提供的工具。

背景說明

這範例的情境是,我們有一份 db.json 設定檔,要做以下三件事:

  1. 非同步讀 db.json 設定檔案
  2. 同步驗證設定是否正確
  3. 在需要時才建立資料庫連線並下 query

補充下,這裡只是稍微模擬一個資料庫連線情境,不是真的有建 db 然後連線下 query,這裡只是簡單模擬此情境下可以如何用 FP 工具實作。

要做到這些事,我們需要 Task、Either 與 IO:

  1. TaskEither<Error, A>:處理「可能失敗的非同步讀檔任務」──讀不到檔案、I/O 例外情況
  2. Either<Error, A>:處理「可能失敗的同步驗證任務」──JSON 解析錯、欄位缺漏
  3. IO<A>:封裝「同步副作用的描述」──真正建立連線等到需要時才執行

在實作程式之前,因為這裡統一用 fp-ts 的工具來撰寫,而 fp-ts 函式庫內是沒有我們之前介紹過的 compose function 的,fp-ts 提供的工具是 pipeflowpipe 和我們之前自己實作的版本幾乎相同,用來把「值」依序送進多個函數。而 flow 則可以理解成是「左到右版本的 compose」。

以下稍微說明 flowpipecompose 的差異。

  • pipe
    • 先有值,再把值往後送。第一個傳入的參數就是要處理的 data
    • 執行方向從左到右
    • 用途:直接計算
    import { pipe } from 'fp-ts/function';
    
    // 從左到右,第一個先傳入 data
    const result = pipe(
      3,                   // 先有初始要處理的 data
      (x: number) => x + 1,
      (y: number) => y * 2
    );
    console.log(result); // 8
    
  • flow
    • 先有函式們,合成出一個新函式,之後才丟值進去。一開始還不會傳入要處理的 data
    • 執行方向從左到右
    • 用途:建立可重用的函式
    import { flow } from 'fp-ts/function';
    
    // 從左到右,但第一個傳入的是函式,最後要使用時才傳入 data
    const f = flow(
      (x: number) => x + 1,
      (y: number) => y * 2
    );
    
    console.log(f(3)); // 8
    
  • compose
    • 先有函式們,合成出一個新函式,之後才丟值進去,或是將要處理的 data 放最後
    • 執行方向從右到左,符合數學的函數組合定義 f(g(x))
    • 用途:建立可重用的函式(fp-ts 沒內建,通常用 flow 取代)
    const compose =
      <A, B, C>(f: (b: B) => C, g: (a: A) => B) =>
      (a: A): C =>
        f(g(a));
    
    const double = (x: number) => x * 2;
    const inc = (x: number) => x + 1;
    
    const f = compose(double, inc); // f(x) = double(inc(x))
    console.log(f(3)); // 8
    

程式實作

有了背景知識,也知道這應用的情境後,來看程式實作吧~
(完整可運作程式碼可參考此連結,再次感謝 olddunk 大大在此篇文章提供了起始專案🙏)

首先我們要先建立一個 db.json 檔案來模擬 db 的設定資訊(實際開發不會這樣直接寫帳號密碼,這只是個模擬範例)

{
  "uname": "monica",
  "pass": "pass123",
  "host": "localhost",
  "db": "mydb"
}

接著寫一個簡單的程式碼模擬資料庫的連線和查詢:

// src/pg.ts
import * as IO from 'fp-ts/IO';

export type Url = string;

export interface DbConnection {
  url: Url;
}

export interface ResultSet {
  rows: Array<{ id: number; name: string }>;
}

// Postgres.connect :: Url -> IO DbConnection
export const Postgres = {
  connect:
    (url: Url): IO.IO<DbConnection> =>
    () => {
      // 這裡就當作連上了(真實世界才會去呼叫 driver)
      console.log('[IO] Establishing DB connection to:', url);
      return { url };
    },

  // runQuery :: DbConnection -> ResultSet
  runQuery: (db: DbConnection): ResultSet => {
    // 純函式,模擬回傳查詢結果
    return {
      rows: [
        { id: 1, name: 'Ada' },
        { id: 2, name: 'Grace' },
      ],
    };
  },
};

再來就是我們的主要程式,我們透過 TaskEither 來讀檔、Either 驗證,然後用 IO 延遲連線和查詢。

1. 用 TaskEither 讀檔

第一步是定義讀取檔案的函式,我們需要從檔案系統讀取設定檔,而這是非同步任務,且可能失敗,所以用 TaskEither<Error, string>,失敗就回傳 Error,成功就回傳字串內容。

// readFile :: String -> TaskEither<Error, string>
const readFileTE = (filename: string): TE.TaskEither<Error, string> =>
  TE.tryCatch(() => fs.readFile(filename, 'utf8'), E.toError);

2. 用 Either 驗證設定並組合 URL

第二步是驗證 db 設定檔中的內容,並組合 URL,因為驗證可能會失敗,所以使用 Either<Error, Url> 來表示驗證結果:

// dbUrl :: Config -> Either<Error, Url>
const dbUrl = (cfg: Config): E.Either<Error, Url> => {
  const { uname, pass, host, db } = cfg;
  return uname && pass && host && db
    ? E.right(`db:pg://${uname}:${pass}@${host}:5432/${db}`)
    : E.left(new Error('Invalid config! (uname/pass/host/db required)'));
};

3. 描述「建立連線」的行為:用 IO 包裝副作用

如果設定合法,就可以產生一個「延遲執行」的資料庫連線。這裡我們用 IO<DbConnection>,它不會立刻執行,而是等我們呼叫時才真正建立連線,所以 connectDb 是一個「描述預計如何執行資料庫連線」的函式,但實際上它還沒有執行、因此目前為止還沒有真的建立資料庫連線。

// connectDb :: Config -> Either<Error, (IO DbConnection)>
const connectDb = flow(
  dbUrl, // Config -> Either<Error, Url>
  E.map(Postgres.connect) // map :: (Url -> IO<DbConnection>) -> Either<Error, IO<DbConnection>>
);

4. 串接:讀檔 → 解析 JSON → 連線

現在我們要把前面幾個步驟接起來:

  • readFileTE 讀檔(TaskEither
  • JSON.parse 把字串轉成 Config
  • connectDb 驗證並建立 IO 連線

這樣就會得到一個 TaskEither<Error, Either<Error, IO<DbConnection>>>

// getConfig :: Filename -> TaskEither<Error, Either<Error, IO<DbConnection>>>
const getConfig = flow(
  readFileTE, // String -> TaskEither<Error, string>
  TE.map(
    // map over TaskEither's Right(string)
    flow(
      (s: string) => JSON.parse(s) as Config, // string -> Config
      connectDb // Config -> Either<Error, IO<DbConnection>>
    )
  )
);

5. 執行副作用

到第四步驟為止,我們都還在定義「預計如何操作」的函式,還沒真的呼叫執行、建立資料庫連線。現在我們要定義一個 main 函式來觸發副作用。

這個函式會有內外層的 Either:

  • 外層 Either:讀檔失敗(檔案不存在)與讀檔成功
  • 內層 Either:設定不合法與設定合法,得到 IO<DbConnection>

IO.map(Postgres.runQuery) 會把查詢映射進 IO,直到最後呼叫才真正執行。

const main = async () => {
  const file = 'db.json'; // 改成 wrongdb.json 可測試錯誤
  const result = await getConfig(file)(); // 執行 Task

  // 內層 Either:處理設定驗證
  const onEither = E.match<Error, IO.IO<DbConnection>, void>(
    (cfgErr) => {
      console.error('設定錯誤:', cfgErr.message);
    },
    (ioConn) => {
      const ioResult: IO.IO<ResultSet> = IO.map(Postgres.runQuery)(ioConn);
      const rs = ioResult(); // 直到這裡才真正連線 + 查詢
      console.log('查詢結果:', rs);
    }
  );

  // 外層 Either:處理讀檔
  E.match<Error, E.Either<Error, IO.IO<DbConnection>>, void>(
    (readErr) => {
      console.error('讀檔失敗:', readErr.message);
    },
    (cfgEither) => {
      onEither(cfgEither);
    }
  )(result);
};

main().catch((e) => {
  console.error('未預期錯誤:', E.toError(e).message);
});

我們把副作用統一放在「應用程式邊界」觸發(例如 main()、框架的 entry / effect layer),核心邏輯保持純粹。在呼叫 main 函式之前,都不會產生任何副作用,我們延遲了資料庫連線的操作,只在需要時執行,來降低副作用帶來的不可控性。

如果還記得之前提過的程式分為 Action、Calculation 和 Data,在 main 函式之前,我們盡量利用 Task 將副作用行為包裝成純粹的 Calculation,減少程式中 Action 的比例,這樣只有 main 是會產生副作用的函式。

6. 完整程式碼

以下是完整的程式實作。

import { promises as fs } from 'fs';
import { flow } from 'fp-ts/function'; // compose
import * as E from 'fp-ts/Either'; // Either
import * as TE from 'fp-ts/TaskEither'; // TaskEither
import * as IO from 'fp-ts/IO'; // IO
import { Postgres, Url, DbConnection, ResultSet } from './pg';

type Config = {
  uname?: string;
  pass?: string;
  host?: string;
  db?: string;
};

// ---------- readFile :: String -> TaskEither<Error, string> ----------
const readFileTE = (filename: string): TE.TaskEither<Error, string> =>
  TE.tryCatch(() => fs.readFile(filename, 'utf8'), E.toError);

// ---------- dbUrl :: Config -> Either<Error, Url> ----------
const dbUrl = (cfg: Config): E.Either<Error, Url> => {
  const { uname, pass, host, db } = cfg;
  return uname && pass && host && db
    ? E.right(`db:pg://${uname}:${pass}@${host}:5432/${db}`)
    : E.left(new Error('Invalid config! (uname/pass/host/db required)'));
};

// ---------- connectDb :: Config -> Either<Error, (IO DbConnection)> ----------
const connectDb = flow(
  dbUrl, // Config -> Either<Error, Url>
  E.map(Postgres.connect) // map :: (Url -> IO<DbConnection>) -> Either<Error, IO<DbConnection>>
);

// ---------- getConfig :: Filename -> TaskEither<Error, Either<Error, IO<DbConnection>>> ----------
const getConfig = flow(
  readFileTE, // String -> TaskEither<Error, string>
  TE.map(
    // map over TaskEither's Right(string)
    flow(
      (s: string) => JSON.parse(s) as Config, // string -> Config
      connectDb // Config -> Either<Error, IO<DbConnection>>
    )
  )
);

// ---------- Impure calling code(觸發副作用) ----------
const main = async () => {
  const file = 'db.json'; // 可試試把檔名改成 wrongdb.json 來看看錯誤路線

  // getConfig(file) :: TaskEither<Error, Either<Error, IO<DbConnection>>>
  const result = await getConfig(file)(); // 執行 Task,得到 Promise<Either<Error, Either<Error, IO<DbConnection>>>>

  // 對應到:either(console.log, map(runQuery))
  const onEither = E.match<Error, IO.IO<DbConnection>, void>(
    (cfgErr) => {
      // Left:設定錯誤
      console.error('設定錯誤:', cfgErr.message);
    },
    (ioConn) => {
      // Right(IO<DbConnection>):把 runQuery 映射進 IO
      const ioResult: IO.IO<ResultSet> = IO.map(Postgres.runQuery)(ioConn);

      // 直到這裡才真正執行 IO(建立連線 + 查詢)
      const rs = ioResult();
      console.log('查詢結果:', rs);
    }
  );

  // 外層 Either:讀檔成功 → 裡面包 Either;讀檔失敗 → Left(Error)
  E.match<Error, E.Either<Error, IO.IO<DbConnection>>, void>(
    (readErr) => {
      // readFile 失敗
      console.error('讀檔失敗:', readErr.message);
    },
    (cfgEither) => {
      // 讀檔成功 → 處理設定的 Either
      onEither(cfgEither);
    }
  )(result);
};

main().catch((e) => {
  console.error('未預期錯誤:', E.toError(e).message);
});

小結

用幾個問題來總結今天和昨天的文章。

為什麼我們需要 IO 與 Task?

為了將不純的副作用(同步與非同步)封裝成純粹的、可組合的值。讓我們能將程式的核心邏輯(如何組合、轉換資料)與程式的邊界(何時、如何執行副作用)徹底分離,保護函式的純粹性與可測試性。

它們與傳統做法的關鍵差異是什麼?

核心差異在於「惰性 (Laziness)」。
IO 與 Task 將「描述」和「執行」徹底分離,給予了我們對於「何時」、「何地」、以及「是否」要觸發副作用的完全控制權。這與立即執行的指令式程式碼不同。

所以,IO 與 Task 到底是什麼?

  • IO:一個同步副作用的惰性容器。它包裹著一個 () => value 的函式,這個函式描述了要執行的動作。它是一個 Functor,可以用 .map 來組合後續的純粹計算
  • Task:一個非同步副作用的惰性容器。它包裹著一個 (reject, resolve) => void 的函式,描述了一個可能成功或失敗的非同步計算。它也是一個 Functor,可以用 .map 來操作未來的成功值。

共同點:它們都是將「不純的動作」具象化為「純粹的數值」的工具,它們將不可控的 Action 轉為沒有副作用、單純的 Data 來傳遞,透過延遲執行來讓程式更有組合和控制的彈性。

Reference


上一篇
[Day 19] IO:處理同步副作用
下一篇
[Day 21] Monad 入門 (1):撫平巢狀的洋蔥
系列文
30 天的 Functional Programming 之旅24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言