Effect-TS: Penanganan Error Fungsional Bertipe di TypeScript

Foto oleh Kurt:S via Wikimedia Commons (CC BY 2.0)
Effect adalah sistem effect fungsional untuk TypeScript, dipublikasikan sebagai paket effect. Ia membiarkan Anda mendeskripsikan sebuah komputasi sebagai nilai immutable yang tipenya membawa tiga hal: apa yang dikembalikan saat sukses, bagaimana ia bisa gagal, dan dependensi apa yang dibutuhkannya. Side effect hanya berjalan saat Anda mengeksekusi deskripsi itu di tepi program.
Kegagalan adalah nilai biasa yang hidup di kanal error tipe Effect, bukan exception yang melompat ke atas stack. Anda memodelkan kegagalan yang diharapkan sebagai kelas tagged error dengan Data.TaggedError, lalu menanganinya dengan catchTag atau catchAll. Compiler melacak union error yang tersisa dan memaksa Anda menangani setiap kasus, jadi tidak ada kegagalan yang ditelan diam-diam.
Tipe Effect ditulis Effect dari Success, Error, Requirements. Success adalah nilai yang dihasilkan saat berjalan, Error adalah union kegagalan yang diharapkan, dan Requirements adalah kumpulan service yang dibutuhkan effect dari context-nya. Saat Anda menggabungkan effect, union error dan requirements menyatu otomatis.
Anda mendeklarasikan service sebagai Tag memakai Context.Tag, dan effect apa pun yang memakainya mengakumulasi Tag itu di kanal requirements-nya. Anda menyuplai implementasi dengan Layer via Effect.provide. Dependensi yang tak terpenuhi adalah type error saat kompilasi alih-alih crash runtime, dan menukar layer live dengan layer test adalah perubahan satu baris tanpa framework mocking.
Tidak. Effect punya kurva belajar nyata dan menambah overhead yang tidak dibutuhkan skrip kecil atau endpoint CRUD tipis. Ia berbuah pada backend berumur panjang dengan kompleksitas nyata pada mode kegagalan, konkurensi, atau perkabelan dependensi. Adopsi secara bertahap di batas satu module sebelum menulis ulang apa pun.

Foto oleh Kurt:S via Wikimedia Commons (CC BY 2.0)
Ringkasan Utama
Effect (library effect-ts) mengodekan sukses, kegagalan, dan dependensi langsung dalam satu tipe: Effect dari Success, Error, Requirements. Error menjadi nilai yang dilacak compiler alih-alih exception tersembunyi, kerja async tersusun tanpa piramida try/catch, dan dependency injection dicek tipenya di titik pemanggilan. Membutuhkan TypeScript 5.4 ke atas dengan mode strict aktif.
Sistem tipe TypeScript luar biasa sampai ada yang gagal. Fungsi yang bertipe mengembalikan User tetap bisa throw, reject, atau mengembalikan null saat runtime, dan signature-nya tidak mengatakan apa pun soal itu. Anda baru tahu di production. Pada sebuah layanan yang saya kelola, saya menghabiskan satu sore memburu unhandled promise rejection yang diam-diam ditelan tiga lapis kode async, dan stack trace-nya menunjuk baris yang hanya jadi pembawa pesan. Kelas bug seperti itulah yang Effect dirancang untuk membuatnya mustahil diabaikan.
Effect adalah sistem effect fungsional untuk TypeScript. Alih-alih menjalankan side effect seketika, Anda membangun deskripsi workflow yang immutable, lalu mengeksekusinya di tepi program. Muatannya ada pada tipe itu sendiri: ia membawa bukan hanya apa yang dikembalikan sebuah komputasi, tetapi setiap cara ia bisa gagal dan segala yang dibutuhkannya untuk berjalan. Artikel ini membahas tipe Effect, error bertipe, menjinakkan async, dan dependency injection dengan Context dan Layer.
Setiap nilai Effect punya tiga parameter tipe, dengan urutan ini: tipe sukses, tipe error, dan tipe requirements. Promise hanya memberi tahu tipe sukses; penolakan bertipe any. Effect membuat ketiganya eksplisit, sehingga signature menjadi kontrak yang ditegakkan compiler. Saat Anda menggabungkan dua effect, union error dan union requirements-nya menyatu otomatis, jadi tidak ada yang hilang diam-diam.
// Effect<Success, Error, Requirements>
import { Effect } from "effect"
// Succeeds with a number, cannot fail, needs nothing.
const answer: Effect.Effect<number> = Effect.succeed(42)
// Always fails with a string in the error channel.
const boom: Effect.Effect<never, string> = Effect.fail("nope")
// Effects are lazy descriptions. Nothing runs until you execute:
Effect.runSync(answer) // 42 (synchronous)
Effect.runPromise(answer) // Promise<42> (for async effects)Baca signature dari kiri ke kanan sebagai kalimat: Effect dari User, UserNotFound atau DbError, Database berarti ini mengembalikan User, bisa gagal dengan UserNotFound atau DbError, dan butuh Database dalam context. Jika kegagalan baru muncul di hilir mana pun, kanal error membesar dan setiap kasus yang belum ditangani muncul sebagai type error saat kompilasi.
Pergeseran intinya: kegagalan adalah nilai biasa yang hidup di kanal error, bukan control-flow yang melompat ke atas stack. Anda memodelkan setiap kegagalan yang diharapkan sebagai kelas tagged error memakai Data.TaggedError, yang menstempel field diskriminan bertipe string ke kelas itu. Diskriminan itulah yang memungkinkan compiler membedakan kasus kegagalan Anda dan memaksa Anda menangani tiap satu.
import { Effect, Data } from "effect"
class UserNotFound extends Data.TaggedError("UserNotFound")<{
readonly id: string
}> {}
class DbError extends Data.TaggedError("DbError")<{
readonly cause: unknown
}> {}
declare const findRow: (id: string) => Effect.Effect<Row, DbError>
const getUser = (id: string) =>
findRow(id).pipe(
Effect.flatMap((row) =>
row === null
? Effect.fail(new UserNotFound({ id }))
: Effect.succeed(row)
)
)
// inferred: Effect<Row, DbError | UserNotFound, never>Untuk memulihkan, catchTag menangani satu kegagalan berdasarkan diskriminannya dan menghapusnya dari kanal error; catchAll menangani semuanya sekaligus. Setelah Anda menangani sebuah tag, compiler mempersempit union error yang tersisa, jadi Anda bisa langsung melihat kegagalan mana yang masih tertunda. Bagian inilah yang berbuah pada kode nyata: tipenya menyusut saat Anda menangani kasus, dan ia tidak akan membiarkan Anda lupa satu pun.
const safe = getUser("u_123").pipe(
Effect.catchTag("UserNotFound", (e) =>
Effect.succeed({ id: e.id, name: "guest" })
),
Effect.catchTag("DbError", (e) => {
console.error(e.cause)
return Effect.succeed({ id: "anon", name: "guest" })
})
)
// inferred error channel is now: never — every case handledMembungkus Promise adalah tempat kebanyakan orang pertama kali merasakan bedanya. Effect.tryPromise menjalankan fungsi yang mengembalikan promise dan membiarkan Anda memetakan penolakan apa pun menjadi error bertipe pilihan Anda, sehingga penolakan bertipe any dari Promise mentah menjadi nilai bernama di kanal error. Dari situ, timeout, retry dengan backoff, dan batas konkurensi adalah kombinator yang Anda tempelkan ke deskripsi alih-alih pipa buatan tangan yang Anda tulis untuk tiap panggilan.
import { Effect, Schedule, Data } from "effect"
class FetchError extends Data.TaggedError("FetchError")<{
readonly status: number
}> {}
const fetchUser = (id: string) =>
Effect.tryPromise({
try: (signal) => fetch(`/api/users/${id}`, { signal }),
catch: () => new FetchError({ status: 0 })
}).pipe(
Effect.filterOrFail(
(res) => res.ok,
(res) => new FetchError({ status: res.status })
),
Effect.flatMap((res) => Effect.promise(() => res.json())),
Effect.timeout("5 seconds"),
Effect.retry(Schedule.exponential("200 millis").pipe(
Schedule.compose(Schedule.recurs(3))
))
)Effect.tryPromise mengoper AbortSignal ke fungsi try Anda. Jika Anda membungkus fetch, teruskan signal itu, atau timeout maupun interupsi akan membatalkan effect sementara request di bawahnya tetap berjalan. Melupakan signal mengubah pembatalan Effect menjadi kebohongan dan membocorkan koneksi saat beban tinggi.
Parameter tipe ketiga, requirements, adalah tempat dependency injection tinggal. Anda mendeklarasikan sebuah service sebagai Tag, pengenal unik yang dilacak compiler. Effect apa pun yang memakai service itu mengakumulasi Tag tersebut di kanal requirements-nya, dan ia tidak akan berjalan sampai Anda menyediakan implementasi. Tidak ada container runtime, tidak ada sihir decorator, dan tidak ada crash provider hilang saat startup: dependensi yang tak terpenuhi adalah type error.
import { Effect, Context, Layer } from "effect"
// 1. Declare the service interface as a Tag.
class Database extends Context.Tag("Database")<
Database,
{ readonly query: (sql: string) => Effect.Effect<Row[]> }
>() {}
// 2. Use it — Database shows up in the requirements channel.
const listUsers = Effect.gen(function* () {
const db = yield* Database
return yield* db.query("SELECT * FROM users")
})
// inferred: Effect<Row[], never, Database>
// 3. Provide an implementation as a Layer.
const DatabaseLive = Layer.succeed(Database, {
query: (sql) => Effect.succeed([/* ...rows */])
})
// 4. Wire it up at the edge — requirements channel becomes never.
Effect.runPromise(listUsers.pipe(Effect.provide(DatabaseLive)))Layer adalah resep yang membangun service, mungkin dari service lain, dan bisa mengakuisisi serta melepas resource dengan aman. Menukar DatabaseLive dengan layer DatabaseTest dalam pengujian adalah perubahan satu baris tanpa framework mocking, karena dependensi selalu berupa input eksplisit alih-alih import tersembunyi. Bagi siapa pun yang datang dari NestJS, ini ide yang sama dengan provider dan module, tetapi perkabelannya diverifikasi oleh type-checker alih-alih diselesaikan saat runtime.
Effect tidak gratis. Kurva belajarnya nyata, pesan error bisa menjadi panjang, dan skrip kecil tidak membutuhkan semua ini. Jujurlah soal trade-off sebelum mengadopsinya ke seluruh tim.
| Perhatian | TypeScript biasa | Effect |
|---|---|---|
| Visibilitas error | Tersembunyi; throw tak bertipe | Bertipe di kanal error |
| Pembatalan async | Perkabelan AbortController manual | Interupsi bawaan |
| Dependency injection | Container runtime atau import | Dicek tipe via requirements |
| Retry dan timeout | Buatan tangan per panggilan | Kombinator Schedule yang komposabel |
| Kurva belajar | Rendah | Curam di awal |
Aturan praktis saya: raih Effect ketika sebuah codebase punya kompleksitas nyata pada mode kegagalan, konkurensi, atau perkabelan dependensi, terutama backend berumur panjang di mana kebenaran saat kegagalan parsial lebih penting daripada jumlah baris kode. Untuk CLI sekali pakai atau endpoint CRUD tipis, async biasa plus tipe Result sudah cukup. Adopsi secara bertahap, di batas satu module lebih dulu, dan biarkan kanal error bertipe membuktikan nilainya sebelum Anda menulis ulang semuanya.