Skip to main content
BlogSoftware Development

Effect-TS: Penanganan Error Fungsional Bertipe di TypeScript

Bagaimana Effect mengodekan sukses, error bertipe, dan dependensi dalam satu signature — dan menjinakkan async di TypeScript tanpa exception tersembunyi.

Matthews Wong

Software Engineer

Juli 20269 menit baca
Effect-TS: Penanganan Error Fungsional Bertipe di TypeScript

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.

Tipe Effect: tiga kanal dalam satu signature

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.

Error bertipe alih-alih exception tersembunyi

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 handled

Menjinakkan async tanpa try/catch

Membungkus 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.

Dependency injection dengan Context dan Layer

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.

Kapan Effect sepadan, dan kapan tidak

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.

PerhatianTypeScript biasaEffect
Visibilitas errorTersembunyi; throw tak bertipeBertipe di kanal error
Pembatalan asyncPerkabelan AbortController manualInterupsi bawaan
Dependency injectionContainer runtime atau importDicek tipe via requirements
Retry dan timeoutBuatan tangan per panggilanKombinator Schedule yang komposabel
Kurva belajarRendahCuram 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.

FAQ

Pertanyaan Umum

Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.

Apa itu Effect (effect-ts) di TypeScript?

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.

Bagaimana Effect menangani error berbeda dari try/catch?

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.

Apa arti tiga parameter tipe Effect?

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.

Bagaimana dependency injection bekerja di Effect?

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.

Haruskah saya memakai Effect untuk setiap proyek TypeScript?

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.