Split View: Effect-TS 심층 — TypeScript에서 이펙트 시스템·DI·리소스·동시성을 한 라이브러리로 푸는 법
Effect-TS 심층 — TypeScript에서 이펙트 시스템·DI·리소스·동시성을 한 라이브러리로 푸는 법
프롤로그 — Promise만으로는 답이 안 되는 순간
다음 코드를 보자. TypeScript로 흔히 짜는 모양이다.
async function chargeCustomer(userId: string, amount: number): Promise<Receipt> {
const user = await db.getUser(userId)
const card = await stripe.getDefaultCard(user.stripeId)
const charge = await stripe.charge(card, amount)
await db.saveReceipt(charge)
await mailer.sendReceipt(user.email, charge)
return toReceipt(charge)
}
여섯 줄에 await가 다섯 번. 잘 동작한다 — 해피 패스에서는. 그런데 이 함수의 시그니처가 우리에게 말해주는 것은 단 두 가지뿐이다.
Promise를 돌려준다.- 성공하면
Receipt다.
말하지 않는 것:
- 어떤 에러가 나는지 —
Error하나로 뭉뚱그려진다. - 어떤 의존성이 필요한지 —
db,stripe,mailer가 클로저로 빨려 들어가 있다. - 중간에 죽으면 청구는 됐는데 영수증만 안 보낸 상태가 될 수 있는지.
- 취소가 어떻게 전파되는지 (
AbortSignal은 어디에?). - 동시에 여러 단계를 병렬화하면 어떤 일이 일어나는지.
리팩터링이 두려운 이유가 여기 있다. 시그니처가 너무 적게 말한다. **이펙트 시스템(effect system)**은 이 말 부족을 타입으로 메우는 접근이다. Haskell의 IO, Scala의 ZIO, F#의 컴퓨테이션 익스프레션, Rust의 Result + 명시적 자원관리 — 이들 모두 같은 가족이다.
TypeScript 진영에서 그 자리를 차지한 라이브러리가 Effect-TS다. 2026년 기준 안정화된 3.x, fp-ts의 저자 Giulio Canti가 합류해 fp-ts를 maintenance mode로 보낸 그 라이브러리. 이 글은 Effect-TS를 해부한다 — Effect<R, E, A>, 연산, 에러, DI, 리소스, 스키마, 스트림, 파이버까지. 그리고 솔직히 언제 쓰면 안 되는지도 본다.
1장 · Effect 타입 — 컨텍스트·에러·값을 한꺼번에 추적한다
Effect-TS의 모든 것은 단 하나의 타입에서 출발한다.
type Effect<A, E, R>
세 개의 파라미터가 각각 무엇을 의미하는지가 핵심이다.
| 자리 | 이름 | 의미 |
|---|---|---|
A | Success | 이 이펙트가 성공했을 때 돌려주는 값의 타입 |
E | Error / Failure | 이 이펙트가 예측한 실패의 타입(유니온 가능) |
R | Requirements (Context) | 실행하려면 환경에 있어야 하는 서비스 |
읽는 법: "Effect는 R을 환경으로 받아 실행되며, E로 실패하거나 A로 성공한다." Promise와 비교하면 차이가 또렷하다.
// Promise: 성공 타입만 보인다
function foo(): Promise<Receipt>
// Effect: 환경·실패·성공이 시그니처에 다 있다
function foo(): Effect.Effect<Receipt, StripeError | DbError, DbService | StripeService | Mailer>
이게 별것 아닌 것 같지만, 리팩터 안전성의 차원이 다르다. 어떤 함수가 새로운 에러를 던지기 시작하면 타입이 깨지고, 어떤 함수가 새 의존성을 필요로 하기 시작하면 호출 위치에서 컴파일러가 손가락질을 한다. Promise는 이 둘을 침묵 속에 흘려보낸다.
이펙트 추적의 핵심: "이 함수가 무엇을 할 수 있는지"가 타입에 새겨진다. 호출자는 시그니처만 보고 "여기서 무엇이 깨질 수 있는가, 무엇이 필요한가"를 안다.
Effect<A, E, R>은 **묘사(description)**다. 즉시 실행되는 게 아니라, "이걸 이 환경에서 실행하면 이런 일이 일어난다"는 레시피다. Effect.runPromise 같은 인터프리터로 끝에서 한 번만 실행한다. 이 지연(lazy) 모델 덕에 동시성·재시도·취소를 안전하게 합성할 수 있다.
2장 · 기본 연산 — map, flatMap, zip, race, all
Effect는 모나드다. 즉 두 가지 연산이 출발점이다.
Effect.succeed(a)—A를 그대로 감싸는 이펙트.Effect.flatMap(eff, a => effB)—A가 나오면 그것으로 다음 이펙트를 만든다.
이것만으로도 모든 시퀀싱이 표현된다. 하지만 매번 flatMap을 적는 건 피곤하다. Effect-TS는 Effect.gen 으로 제너레이터 기반의 코루틴 표기를 준다 — 사실상 async/await 같은 모양으로 이펙트를 시퀀싱한다.
import { Effect } from "effect"
const program = Effect.gen(function* () {
const user = yield* getUser(userId)
const card = yield* getDefaultCard(user.stripeId)
const charge = yield* chargeCard(card, amount)
yield* saveReceipt(charge)
yield* sendReceipt(user.email, charge)
return toReceipt(charge)
})
yield*가 await처럼 보이지만, 타입 시스템 안에서는 훨씬 풍부한 정보를 추적한다. 각 yield* 결과에서 환경 R과 에러 E가 합쳐지며, 마지막 시그니처는 모든 의존성과 가능한 모든 실패를 자동으로 합쳐 보여준다.
주요 연산 빠르게
Effect.map(eff, fn)— 성공 값을 변환.Effect.flatMap(eff, fn)— 성공 값을 다음 이펙트로.Effect.zip(a, b)— 둘 다 성공해야 성공. 결과는 튜플.Effect.all([a, b, c])— 여러 개 합성. 옵션으로 동시성, fail-fast/모두 수집.Effect.race(a, b)— 먼저 끝나는 쪽 채택. 나머지는 자동 취소.Effect.either(eff)— 실패도 값으로 받아온다 (Either변환).Effect.catchAll(eff, e => recover)— 실패 복구.Effect.timeout(eff, "5 seconds")— 시간 초과.Effect.retry(eff, schedule)— 재시도(스케줄로 백오프 표현).
Effect.all은 동시성 옵션이 강력하다.
Effect.all([fetchA, fetchB, fetchC], { concurrency: "unbounded" })
Effect.all(tasks, { concurrency: 8, mode: "either" })
mode: "either"는 일부가 실패해도 다른 결과를 끝까지 거둔다. Promise.allSettled를 닮았지만, 타입은 더 정확하고, 동시성 한도가 라이브러리 차원에서 강제된다.
3장 · 에러 — failures와 defects는 다른 종류다
이펙트 시스템에서 가장 비싸게 배우는 교훈: 모든 실패가 같은 종류는 아니다.
Effect는 두 가지를 명확히 구분한다.
| 범주 | 무엇 | 타입에서 | 예시 |
|---|---|---|---|
| Failure | 예측한 실패 | E에 나타남 | UserNotFound, PaymentDeclined, RateLimited |
| Defect | 예상 못한 버그 | E에 안 나타남 | null 역참조, 무한 루프, 다운된 시스템 |
도메인 실패는 타입에 적어두고 복구한다. 버그는 잡지 않고 위로 던져 보낸다 — 잡으면 진짜 버그가 숨는다. 이 구분이 명확하지 않으면 try/catch 한 줄이 진짜 버그를 삼킨다.
도메인 에러 정의
import { Data } from "effect"
class UserNotFound extends Data.TaggedError("UserNotFound")<{
userId: string
}> {}
class PaymentDeclined extends Data.TaggedError("PaymentDeclined")<{
reason: string
code: number
}> {}
Data.TaggedError는 판별 가능한 유니온(discriminated union)을 위한 빌더다. 각 에러에 _tag가 박혀서 switch로 좁히기가 깔끔하다.
const recovered = program.pipe(
Effect.catchTags({
UserNotFound: (e) => Effect.succeed(guestReceipt),
PaymentDeclined: (e) => logAndNotify(e),
}),
)
여기서 압권은: catchTags로 일부만 처리하면 남은 에러는 시그니처에 그대로 남는다. 모든 케이스를 처리하지 않은 채 함수를 끝내면 시그니처가 그것을 드러낸다. Promise의 try/catch에는 이런 보증이 없다.
Promise vs Effect — 같은 일
Promise:
async function charge() {
try {
const u = await getUser(id)
return await stripe.charge(u, amt)
} catch (e) {
// e의 타입은 unknown. 어떤 에러? 모른다.
throw e
}
}
Effect:
const charge = Effect.gen(function* () {
const u = yield* getUser(id) // E: UserNotFound
return yield* stripeCharge(u, amt) // E: StripeError
})
// 추론된 시그니처: Effect<Receipt, UserNotFound | StripeError, R>
복구할 때 어떤 에러를 다루고 있는지 컴파일러가 정확히 알려준다. 새 에러가 추가되면 catchAll/catchTags가 안 잡힌 케이스를 비추거나, 시그니처가 변한다.
4장 · 의존성 주입 — Context.Tag와 Layer
Effect<A, E, R>의 R이 진짜 가치를 발휘하는 자리. Effect-TS의 DI는 다른 프레임워크의 컨테이너가 아니라 타입으로 표현되는 환경이다.
import { Context, Effect, Layer } from "effect"
// 1. 서비스의 인터페이스를 Tag로 선언
class DbService extends Context.Tag("DbService")<
DbService,
{
readonly getUser: (id: string) => Effect.Effect<User, UserNotFound>
readonly saveReceipt: (r: Charge) => Effect.Effect<void, DbError>
}
>() {}
// 2. 사용 — yield* Tag 로 꺼낸다
const fetchUser = (id: string) =>
Effect.gen(function* () {
const db = yield* DbService
return yield* db.getUser(id)
})
// 추론: Effect<User, UserNotFound, DbService>
DbService를 호출하는 곳마다 R에 자동으로 DbService가 추가된다. 컴파일러가 "이 이펙트를 실행하려면 환경에 DbService가 필요하다"를 기록한다.
Layer — 환경 만들기
Layer는 서비스의 구현을 묶어 환경으로 빚는다.
const DbLive = Layer.succeed(DbService, {
getUser: (id) => /* 실제 구현 */,
saveReceipt: (c) => /* 실제 구현 */,
})
const DbTest = Layer.succeed(DbService, {
getUser: (id) => Effect.succeed(testUser),
saveReceipt: () => Effect.void,
})
const program = fetchUser("u1").pipe(Effect.provide(DbLive))
// ▲
// 테스트에선 DbTest를 끼우면 끝
여러 Layer는 Layer.merge/Layer.provide로 조합한다. 서비스 간 의존성도 타입으로 잡힌다 — DbService가 Config를 필요로 하면, Config를 안 끼운 Layer는 컴파일 에러다.
이건 DI 컨테이너가 아니다. 런타임 매직이 없다. 그저 함수 인자와 클로저의 변형일 뿐인데, 타입으로 환경을 추적하므로 컨테이너의 진단 가치를 얻으면서 동적 디스패치 비용은 없다.
5장 · 리소스 관리 — Scope, acquireRelease, using
자원이 새는 코드의 패턴은 익숙하다. 파일을 열고, 가운데서 예외가 나고, 닫는 코드가 안 도는 것.
// Promise — 까딱하면 누수
async function processFile() {
const f = await openFile(path)
const data = await parse(f) // 실패하면?
await closeFile(f) // 도달 못함
}
finally로 막을 수 있지만, 여러 자원이 얽히고 동시성이 끼면 손이 모자란다. Effect는 Scope라는 추상으로 이 문제를 풀었다.
acquireRelease
const file = Effect.acquireRelease(
openFile(path), // 획득
(f) => Effect.promise(() => f.close()) // 해제 (반드시 실행)
)
const program = Effect.gen(function* () {
const f = yield* Effect.scoped(file) // scope 안에서 라이프 사이클 관리
return yield* parse(f)
})
Effect.scoped는 블록을 만든다 — 블록을 벗어나면 성공·실패·취소 어느 경로로든 해제가 보장된다. 여러 자원을 같은 scope에 등록하면 역순으로 안전하게 풀린다.
using — TC39 명시적 자원 관리와의 연결
ECMAScript의 using 선언(Stage 3)은 같은 아이디어를 언어 레벨로 끌어왔다. Effect는 이미 그 의미론을 라이브러리 안에서 일관되게 구현해왔다 — using이 깔리면 둘 다 자연스럽게 통합된다.
리소스 안전성은 분산 시스템에서 누수의 1순위 원인이다. Effect가 이 자리에서 빛난다.
6장 · Effect Schema — 런타임과 컴파일타임을 한 정의로
타입스크립트의 한계: 컴파일 시점의 안전성이 런타임 데이터(JSON, env, query params)까지 미치지 못한다. zod가 이 자리를 차지해왔고, 2026년에는 또 다른 강자가 있다 — Effect Schema (effect/Schema).
import { Schema as S } from "effect"
const User = S.Struct({
id: S.String,
email: S.String.pipe(S.pattern(/^[^@]+@[^@]+$/)),
age: S.Number.pipe(S.between(0, 150)),
role: S.Literal("admin", "user", "guest"),
})
type User = S.Schema.Type<typeof User> // 타입 추론
const decoded = S.decodeUnknownEither(User)(jsonBlob)
// ▲ Either<User, ParseError>
zod 대비 차별점:
- 양방향(Bidirectional) — encode와 decode가 정의로부터 동시에 나온다. Date 같은 비-JSON 타입을 자연스럽게 다룬다.
- 이펙트 통합 —
S.decode(schema)(data)가Effect<A, ParseError>로 떨어진다. 검증을 다른 이펙트와 그대로 합성한다. - 변환(Transformations) —
S.transform으로 "문자열을 Date로", "트림+소문자" 같은 변환을 스키마 자체에 박아둔다. - Branded types —
S.brand로 명목 타입을 만든다.UserId와OrderId를 동일 string에서 구분.
변환 한 예
const ISODate = S.Date.pipe(S.transform(
S.String,
{ decode: (s) => new Date(s), encode: (d) => d.toISOString() }
))
API 경계에서 한 번만 데이터를 검증/변환하고, 내부에서는 정확한 타입으로만 다루는 코드를 짜기가 쉬워진다.
7장 · Effect Stream — push·pull을 한 모델로
대량 데이터, 백프레셔, 무한 스트림 — Promise/AsyncIterator로는 다루기 까다로운 자리다. Effect Stream은 pull 기반으로 설계됐지만 push도 자연스럽게 흡수한다.
import { Stream } from "effect"
const numbers = Stream.range(1, 1_000_000)
const program = numbers.pipe(
Stream.map((n) => n * 2),
Stream.filter((n) => n % 3 === 0),
Stream.take(100),
Stream.run(Sink.collectAll),
)
Stream은 게으르다. 끝의 Stream.run이 흐름을 시작한다. 중간 변환은 backpressure로 자연스레 조절된다 — 다운스트림이 빠지지 못하면 업스트림이 멈춘다.
흔한 패턴
Stream.fromAsyncIterable— AsyncIterator를 그대로 흡수.Stream.fromQueue— push 소스(웹훅, 이벤트)에서 받아 처리.Stream.throttle— 주기적 제어.Stream.broadcast— 한 소스를 N개 consumer에.Stream.fromSchedule— 시간 기반 트리거.
Node Streams의 백프레셔 모델이 까다로운 이유는 누가 멈출지 명시적이지 않아서다. Effect Stream은 그 결정을 타입과 의미론으로 라이브러리에 내장했다.
8장 · Fiber와 구조적 동시성
Effect의 동시성 기본 단위는 Fiber다. 가볍고, 협력적(co-operative)이며, 취소가 전파된다. Promise/Worker가 아니라 — 그린 스레드에 가깝다.
const a = Effect.delay(longFetch(...), "100 millis")
const b = longCompute(...)
// 동시 실행, 둘 다 끝나면 합친다
const both = Effect.all([a, b], { concurrency: 2 })
// 먼저 끝나는 쪽 사용 — 나머지 fiber는 자동 cancel
const fastest = Effect.race(a, b)
구조적 동시성이 의미하는 것
핵심: 부모의 수명을 자식이 못 넘는다. race로 a가 이기면 b는 즉시 인터럽트된다. acquireRelease로 잡은 자원은 인터럽트 시점에 풀린다. 부모 fiber가 죽으면 자식 fiber도 다 죽는다. "어디선가 백그라운드 작업이 살아 남아서 자원을 점유하는" 시나리오가 안 일어난다.
취소 전파
const stoppable = Effect.gen(function* () {
yield* Effect.log("작업 시작")
yield* Effect.sleep("10 seconds")
yield* Effect.log("완료 — 이 줄은 절대 안 보일 것")
})
const fiber = yield* Effect.fork(stoppable)
yield* Effect.sleep("1 second")
yield* Fiber.interrupt(fiber) // 즉시 취소, 자원 정리 보장
Promise에서는 취소가 일등 시민이 아니다 — AbortSignal로 부분 흉내를 내지만 라이브러리마다 들쭉날쭉하다. Effect에서는 모든 이펙트가 인터럽트 가능하고, scope가 정리를 보장한다.
9장 · 인터옵 — Promise·Callback·AbortSignal과 살기
현실의 코드는 Promise·콜백·라이브러리 API와 섞인다. Effect는 그 경계를 깔끔하게 다룬다.
Promise → Effect
const fetchUser = Effect.tryPromise({
try: () => fetch(`/api/user/${id}`).then(r => r.json()),
catch: (e) => new FetchError({ cause: e }),
})
Effect.promise는 절대 실패 안 하는 promise용, Effect.tryPromise는 실패를 도메인 에러로 매핑한다.
Callback → Effect
const readFile = (path: string) =>
Effect.async<Buffer, NodeJS.ErrnoException>((resume) => {
fs.readFile(path, (err, data) => {
if (err) resume(Effect.fail(err))
else resume(Effect.succeed(data))
})
})
Effect.async는 1회성 콜백을, Effect.asyncEffect는 정리(cleanup)도 필요한 콜백을 다룬다.
AbortSignal 다리
const withAbort = (signal: AbortSignal) =>
Effect.async<Data, FetchError>((resume) => {
const c = new AbortController()
signal.addEventListener("abort", () => c.abort())
fetch(url, { signal: c.signal })
.then(r => resume(Effect.succeed(r)))
.catch(e => resume(Effect.fail(new FetchError({ cause: e }))))
})
Effect → Promise는 Effect.runPromise 한 줄로 끝난다. 즉, Effect는 인터옵에서 절대 격리된 섬이 아니다. 가장자리는 Promise/콜백 세계와 다리를 놓고, 안쪽은 Effect로만 유지한다.
10장 · fp-ts에서 Effect-TS로 — 합병의 의미
Effect-TS의 역사는 TypeScript 함수형 진영의 작은 사건이다.
- 2017~ fp-ts (Giulio Canti) — Haskell/PureScript의 타입 클래스를 TypeScript로 옮긴 라이브러리. ADT, 모나드, 렌즈까지. 학구적이지만 사랑받았다.
- 2020~ Effect-TS (Michael Arnaldi 등) — ZIO의 영향을 강하게 받은 새 접근. 단일
Effect타입 중심, 실제 production 지향. - 2023 Giulio Canti가 Effect-TS 팀에 합류. fp-ts는 사실상 maintenance mode로. Effect-TS가 사실상의 후계자로 자리매김.
- 2024 Effect 3.0. API 안정화. 패키지 구조 정리(
effect단일 패키지로 통합). - 2026 3.x 안정. Schema·Stream·Match·Worker·CLI 등 동심원 패키지 성숙.
fp-ts가 못 쓰게 된 건 아니다 — 동작한다. 하지만 새 코드라면 Effect-TS가 분명한 선택지다. 같은 사람들이 그쪽에서 일하고, 도구·문서·도입 사례·생태계가 그쪽에 모인다.
실제 도입 사례
- Bun — 일부 도구체인 내부에 Effect를 채택 (관찰 가능: 공개 코드의 사용).
- Effect Studio, Disney Streaming, Vercel의 일부 팀에서 사용 보고(컨퍼런스 토크 기준).
- 다수 OSS — Schema를 zod 대안으로 쓰는 라이브러리, Effect 위에 빌드된 백엔드 프레임워크.
규모 있는 TypeScript 백엔드에서 "관리 가능한 복잡도"가 중요해지면, Effect는 자주 등장한다.
11장 · 비교 — Promise·Result·ZIO·Rust Result
Effect만이 답은 아니다. 같은 자리에 다른 도구들이 있다.
Plain Promise
- 장점: 표준, 모두가 안다, 작은 코드에 무리 없다.
- 단점: 에러 타입이
unknown, 환경/취소가 일등 시민이 아님. - 언제: 단일 함수·단일 모듈·짧은 스크립트·라이브러리 가장자리.
Result/Either (neverthrow, ts-belt, fp-ts Either)
- 장점: 에러 타입을 잡는다, 가벼움.
- 단점: 동시성·취소·DI는 별도 도구 필요.
- 언제: 에러만 명시적으로 추적하고 싶을 때. Effect 도입 전 단계로도.
ZIO (Scala)
- 장점: Effect-TS의 영적 형제. 더 오래된 생태계.
- 언어 차이: Scala에서만 작동. TypeScript에서는 Effect가 사실상 ZIO.
Rust Result + lifetimes
- 장점: 에러·자원·소유권을 컴파일러가 통째로 강제.
- 언어 차이: 다른 패러다임. JS 인프라와는 거리.
F# 컴퓨테이션 익스프레션 / Haskell IO
- 장점: 더 깊은 타입 시스템, 일관된 이펙트 추적.
- 언어 차이: 같은 자리. Effect-TS는 그 정신을 TS에 옮긴 것.
한 줄 요약
| 사용 시나리오 | 추천 |
|---|---|
| 짧은 스크립트, 단일 모듈 | Plain Promise |
| 에러를 좀 더 잡고 싶음 | Result 라이브러리 |
| 큰 백엔드, 리팩터 안전성·DI·동시성 일관성 | Effect-TS |
| Scala 생태계 | ZIO |
| 시스템 프로그래밍 | Rust Result + lifetimes |
12장 · 언제 Effect가 옳고 언제 과한가
Effect는 무료가 아니다. 학습 곡선, 런타임 오버헤드, 팀 동의 — 모두 비용이다.
Effect가 옳은 경우
- 백엔드가 중대형 이상이고 라이프타임이 수 년 단위.
- 리팩터 안전성이 비즈니스 가치다 (결제, 자료 무결성, 인앱 구매).
- 테스트 가능성이 중요 — DI/Layer가 단위 테스트를 단순화한다.
- 자원 관리(파일, 커넥션, 락)가 복잡하다.
- 동시성·취소가 자주 등장한다.
- 팀이 함수형/이펙트 패러다임에 익숙하거나 학습 의지가 있다.
과한 경우 (Don't)
- CLI 한 개, 간단한 스크립트, 프로토타입.
- 팀이 다 신참이고, 도메인이 안정되지 않은 작은 스타트업.
- React 컴포넌트 내부에서만 쓰는 UI 로직 (UI 가장자리에는 적합하지만 일관 도입은 부담).
- 라이브러리 저자 — 사용자에게 Effect 의존성을 강요하기 어렵다.
신호: "코드가 점점 try/catch와 클로저 의존성으로 누더기가 되고, 리팩터 한 번에 일주일이 사라진다" — Effect의 자리다. "200줄짜리 스크립트인데 분당 한 번 도는 cron이다" — 그냥 async/await로 됐다.
13장 · 도입 전략 — 한 번에 갈아엎지 마라
Effect 도입의 가장 흔한 실수: 모든 코드를 한 번에 옮기려 든다. 그러면 PR이 1만 줄짜리가 되고, 리뷰가 막히고, 팀은 화낸다.
단계적 도입
- 가장자리부터 — 한 모듈/서비스에서 시작. 외부 경계는 Promise를 유지하고, 안쪽만 Effect.
- Schema부터 — DI/Stream 없이 Effect Schema만 도입해도 검증 코드가 깔끔해진다.
- Result 단계 거치기 — 먼저
Either/Result로 에러만 잡고, 익숙해지면 Effect로. - Layer 도입 — 핵심 서비스부터 Layer로 추출. 테스트 더블이 따라온다.
- 새 코드만 — 기존 코드는 그대로, 새 모듈은 Effect로.
팀 학습
- 처음 2주: Effect 타입 읽기만 익숙해진다.
- 그다음 2주:
Effect.gen+ 기본 연산. - 그다음: 에러 분류, Layer, Scope.
- 마지막: Stream, Fiber.
함수형 라이브러리를 처음 도입하면 사람들이 "이건 마법 같다"고 한다. Effect는 그 마법을 줄이고 읽기 쉬운 모양을 많이 갖췄지만, 그래도 패러다임 전환이다.
14장 · 짧은 미니 예제 — 결제 파이프라인
처음 본 결제 코드를 Effect로 다시 써보자.
import { Effect, Layer, Data, Context, Schema as S } from "effect"
class UserNotFound extends Data.TaggedError("UserNotFound")<{ id: string }> {}
class PaymentDeclined extends Data.TaggedError("PaymentDeclined")<{ code: number }> {}
class DbService extends Context.Tag("DbService")<DbService, {
readonly getUser: (id: string) => Effect.Effect<User, UserNotFound>
readonly saveReceipt: (c: Charge) => Effect.Effect<void>
}>() {}
class StripeService extends Context.Tag("StripeService")<StripeService, {
readonly getDefaultCard: (id: string) => Effect.Effect<Card>
readonly charge: (card: Card, amt: number) => Effect.Effect<Charge, PaymentDeclined>
}>() {}
class MailerService extends Context.Tag("MailerService")<MailerService, {
readonly sendReceipt: (to: string, c: Charge) => Effect.Effect<void>
}>() {}
const chargeCustomer = (userId: string, amount: number) =>
Effect.gen(function* () {
const db = yield* DbService
const stripe = yield* StripeService
const mailer = yield* MailerService
const user = yield* db.getUser(userId)
const card = yield* stripe.getDefaultCard(user.stripeId)
const charge = yield* stripe.charge(card, amount)
yield* db.saveReceipt(charge)
yield* mailer.sendReceipt(user.email, charge)
return toReceipt(charge)
})
// 추론된 시그니처:
// Effect<Receipt,
// UserNotFound | PaymentDeclined,
// DbService | StripeService | MailerService>
읽는 사람은 시그니처만 봐도 안다:
- 무엇을 돌려주는지:
Receipt. - 무엇이 실패할 수 있는지:
UserNotFound,PaymentDeclined— 그것이 전부. - 무엇이 필요한지:
DbService,StripeService,MailerService.
테스트는 세 서비스의 Mock Layer를 끼우면 된다. 새 에러가 생기면 컴파일러가 비춘다. 새 의존성이 생기면 시그니처가 변한다. 이게 이펙트 추적의 가치다.
에필로그 — 이펙트는 타입을 더 말하게 만든다
Effect-TS의 한 줄 요약: 타입이 더 많이 말한다. 함수가 무엇을 돌려주는지뿐 아니라, 무엇이 실패할 수 있는지, 무엇이 필요한지, 어떻게 취소되는지가 시그니처 안에 새겨진다.
async/await가 콜백을 대체한 것처럼, Effect는 async/await를 대체하려는 게 아니다 — 그 위에 더 풍부한 의미론을 얹는다. 시간이 지나면 using 같은 ECMAScript 표준이 그 의미론의 일부를 언어 레벨로 가져온다. Effect는 그 길의 앞쪽을 라이브러리로 빨리 보여주는 존재다.
큰 백엔드의 5년 차에 "이 함수가 뭘 던지지, 뭘 필요로 하지"를 알기 위해 코드를 처음부터 다 읽어야 한다면 — 거기가 Effect의 자리다. 작은 스크립트라면 — 굳이.
12개 항목 체크리스트
- 도메인 에러를
Data.TaggedError로 정의했는가? - 함수 시그니처에 에러 유니온이 정직하게 드러나는가?
try/catch로 도메인 실패와 defect를 섞어 잡고 있지 않는가?- 서비스를
Context.Tag+Layer로 분리했는가? - 테스트용 Layer가 운영용과 분리되어 있는가?
- 자원 획득은
acquireRelease/scoped로 감싸는가? - 동시 처리에
Effect.all의 동시성 옵션을 설정했는가? Effect.race로 묶은 후 패배자가 정리되는지 확인했는가?- 외부 데이터는
Effect Schema로 한 번만 검증하는가? - Stream은
Stream.run전까지 게으르게 유지하는가? - Promise/콜백 경계에서
tryPromise/async로 매핑하는가? - 도입 전략이 단계적인가 — 한 번에 갈아엎지 않는가?
안티패턴 10가지
- 도메인 실패를
Error한 종류로 뭉뚱그리기. try/catch로 defect까지 삼키기 — 진짜 버그를 가린다.- DI 없이 모듈 톱레벨에서 클라이언트를 import 하기 — 테스트 막힘.
acquireRelease없이 파일/커넥션을 그냥 열기.Effect.all의 동시성 한도를 빼먹어 N×요청 폭주.Effect.race로 묶고 패배자 정리를 안 한다고 착각하기.- Stream을 게으름 없이 즉시 실행으로 쓰기.
- 큰 코드베이스를 한 PR에 다 갈아엎으려 하기.
- 라이브러리 저자가 사용자에게 Effect 의존을 강요.
- 200줄짜리 CLI에 Effect를 도입하고 학습 비용을 정당화하기.
다음 글 예고
다음 글 후보: Effect Schema 심층 — zod와의 차이, 변환, 브랜드 타입, Effect Stream으로 백프레셔 파이프라인 짜기, Effect Layer로 마이크로서비스 부트스트랩 — 환경·설정·로깅·트레이싱.
"타입이 더 많이 말하면, 리팩터는 덜 두렵다. Effect는 그 약속이다."
— Effect-TS 심층, 끝.
참고 / References
- Effect — Official site
- Effect Documentation
- Effect GitHub — Effect-TS/effect
- Effect Schema — Documentation
- Effect Stream — Documentation
- Effect — Why Effect
- fp-ts — Original library
- fp-ts GitHub — gcanti/fp-ts
- Effect 3.0 announcement
- Giulio Canti joins Effect — announcement
- ZIO — Scala effect system
- neverthrow — Result for TypeScript
- ts-belt — Functional helpers
- zod — schema validation
- TC39 Explicit Resource Management proposal
- Effect Days conference
- Bun runtime
- Vercel engineering blog
- Michael Arnaldi — Effect talks
- ReScript Result and effect community
- Haskell IO monad
- Rust ? operator and Result
- F# Computation Expressions
- Effect Schema vs zod — community discussion
- Structured concurrency — Wikipedia
Effect-TS Deep Dive — Solving Effects, DI, Resources, and Concurrency in One TypeScript Library
Prologue — The Moment Promise Stops Being Enough
Look at the code below. It is the shape most TypeScript apps still ship.
async function chargeCustomer(userId: string, amount: number): Promise<Receipt> {
const user = await db.getUser(userId)
const card = await stripe.getDefaultCard(user.stripeId)
const charge = await stripe.charge(card, amount)
await db.saveReceipt(charge)
await mailer.sendReceipt(user.email, charge)
return toReceipt(charge)
}
Six lines, five awaits. It works — on the happy path. But the signature of this function tells you only two things.
- It returns a
Promise. - On success, you get a
Receipt.
What it does not say:
- Which errors can be thrown — lumped under
Error. - Which dependencies it requires —
db,stripe,mailerare sucked in via closure. - Whether a mid-flight failure leaves the customer charged but un-emailed.
- How cancellation propagates (where is
AbortSignal?). - What happens when stages are parallelized.
That is why refactors are scary. The signature is too quiet. An effect system is the approach that fills the silence with types — Haskell's IO, Scala's ZIO, F# computation expressions, Rust's Result plus explicit ownership: same family.
In TypeScript, the library that claims that seat is Effect-TS. Stable on 3.x in 2026, with fp-ts author Giulio Canti having joined the team and moved fp-ts into maintenance. This piece dissects Effect-TS — Effect<R, E, A>, the operations, the error model, DI, resources, schemas, streams, fibers — and is honest about when not to use it.
1. The Effect Type — Context, Error, and Value Tracked Together
Everything starts with one type.
type Effect<A, E, R>
Three parameters, each non-trivial.
| Slot | Name | Meaning |
|---|---|---|
A | Success | The value produced on success |
E | Error / Failure | The set of anticipated failures (a union) |
R | Requirements (Context) | Services that must be in the environment to run |
Read it as: "An Effect runs in environment R, fails with E, or succeeds with A." Compare to Promise:
// Promise — only the success type shows
function foo(): Promise<Receipt>
// Effect — environment, failure, and success are all in the signature
function foo(): Effect.Effect<Receipt, StripeError | DbError, DbService | StripeService | Mailer>
This looks cosmetic but changes the refactor calculus. When a function starts throwing a new error, the type breaks. When it starts needing a new service, the compiler points at every call site. Promises swallow both silently.
The core idea of effect tracking: "What this function can do" is etched into its type. Callers see, at the signature, what may fail and what is required.
Effect<A, E, R> is a description. It does not run on creation; it is a recipe that, when executed in a given environment, produces the described behaviour. You run it once at the edge with Effect.runPromise. This lazy model is what makes concurrency, retry, and cancellation composable.
2. Core Operations — map, flatMap, zip, race, all
Effect is a monad. Two operations form the base:
Effect.succeed(a)— liftAinto an effect.Effect.flatMap(eff, a => effB)— chain on the success value.
Every sequencing pattern follows from those two. But writing flatMap everywhere is tiring. Effect-TS provides Effect.gen, a generator-based coroutine notation — looks like async/await, sequences effects.
import { Effect } from "effect"
const program = Effect.gen(function* () {
const user = yield* getUser(userId)
const card = yield* getDefaultCard(user.stripeId)
const charge = yield* chargeCard(card, amount)
yield* saveReceipt(charge)
yield* sendReceipt(user.email, charge)
return toReceipt(charge)
})
yield* resembles await, but the type system carries more. Each yield* accumulates R and E, so the final signature is the auto-merged union of every dependency and every possible failure.
Operations worth knowing
Effect.map(eff, fn)— transform the success.Effect.flatMap(eff, fn)— chain on the success.Effect.zip(a, b)— both must succeed; result is a tuple.Effect.all([a, b, c])— combine many. Options for concurrency, fail-fast vs collect.Effect.race(a, b)— first to finish wins. The other is auto-cancelled.Effect.either(eff)— fold failure into a value (Either).Effect.catchAll(eff, e => recover)— recover from failure.Effect.timeout(eff, "5 seconds")— bound time.Effect.retry(eff, schedule)— retry with aSchedule(back-off, jitter, etc.).
Effect.all is particularly strong:
Effect.all([fetchA, fetchB, fetchC], { concurrency: "unbounded" })
Effect.all(tasks, { concurrency: 8, mode: "either" })
mode: "either" collects results even when some fail — like Promise.allSettled but with proper types and a library-enforced concurrency cap.
3. Errors — Failures and Defects Are Not the Same
The expensive lesson of any effect system: not all failures are alike.
Effect makes two categories explicit.
| Category | Meaning | In the type | Examples |
|---|---|---|---|
| Failure | Anticipated, domain-shaped | Shows in E | UserNotFound, PaymentDeclined, RateLimited |
| Defect | An unexpected bug | Not in E | null dereference, infinite loops, dead system |
Domain failures live in the type and are recovered from. Defects are bugs — let them propagate; catching them hides real problems. If you blur this line, a single try/catch can swallow your real bugs.
Defining domain errors
import { Data } from "effect"
class UserNotFound extends Data.TaggedError("UserNotFound")<{
userId: string
}> {}
class PaymentDeclined extends Data.TaggedError("PaymentDeclined")<{
reason: string
code: number
}> {}
Data.TaggedError builds discriminated-union-friendly errors with a _tag. You can narrow them with switch-like helpers.
const recovered = program.pipe(
Effect.catchTags({
UserNotFound: (e) => Effect.succeed(guestReceipt),
PaymentDeclined: (e) => logAndNotify(e),
}),
)
The killer feature: when catchTags handles only some cases, the remaining errors stay in the signature. Leaving a case unhandled is visible at the type level. try/catch has no such guarantee.
Promise vs Effect — same task
Promise:
async function charge() {
try {
const u = await getUser(id)
return await stripe.charge(u, amt)
} catch (e) {
// e is `unknown`. Which error? You don't know.
throw e
}
}
Effect:
const charge = Effect.gen(function* () {
const u = yield* getUser(id) // E: UserNotFound
return yield* stripeCharge(u, amt) // E: StripeError
})
// Inferred: Effect<Receipt, UserNotFound | StripeError, R>
When recovering, the compiler tells you exactly which errors you're handling. Add a new failure and an unhandled case shows up immediately.
4. Dependency Injection — Context.Tag and Layer
The R slot of Effect<A, E, R> earns its keep here. Effect's DI is not a runtime container; it is the environment, expressed in types.
import { Context, Effect, Layer } from "effect"
// 1. Declare a service by Tag
class DbService extends Context.Tag("DbService")<
DbService,
{
readonly getUser: (id: string) => Effect.Effect<User, UserNotFound>
readonly saveReceipt: (r: Charge) => Effect.Effect<void, DbError>
}
>() {}
// 2. Use it — yield* the Tag
const fetchUser = (id: string) =>
Effect.gen(function* () {
const db = yield* DbService
return yield* db.getUser(id)
})
// Inferred: Effect<User, UserNotFound, DbService>
Wherever you reach for DbService, it joins R in the inferred type. The compiler tracks "this effect requires DbService in the environment."
Layer — building the environment
Layer packages service implementations into an environment.
const DbLive = Layer.succeed(DbService, {
getUser: (id) => /* real impl */,
saveReceipt: (c) => /* real impl */,
})
const DbTest = Layer.succeed(DbService, {
getUser: (id) => Effect.succeed(testUser),
saveReceipt: () => Effect.void,
})
const program = fetchUser("u1").pipe(Effect.provide(DbLive))
// ^
// For tests, swap in DbTest. That's it.
Multiple layers compose via Layer.merge / Layer.provide. Inter-service dependencies are typed: if DbService needs Config, a layer that omits Config is a compile error.
This is not a DI container. No runtime magic — it is functions and closures, but the environment travels in the types. You gain the diagnostic value of a container without dynamic dispatch overhead.
5. Resource Management — Scope, acquireRelease, and using
Leak patterns are familiar. You open a file, something throws midway, the close never runs.
// Promise — leaks waiting to happen
async function processFile() {
const f = await openFile(path)
const data = await parse(f) // throws?
await closeFile(f) // never reached
}
finally papers over it for simple cases. Once you have multiple resources and concurrency, the hand-coded approach falls apart. Effect solves this with Scope.
acquireRelease
const file = Effect.acquireRelease(
openFile(path), // acquire
(f) => Effect.promise(() => f.close()) // release (guaranteed)
)
const program = Effect.gen(function* () {
const f = yield* Effect.scoped(file) // life-cycle bound to the scope
return yield* parse(f)
})
Effect.scoped opens a block. When the block ends — by success, failure, or interruption — release runs. Register several resources in one scope and they unwind in reverse order safely.
using — meeting TC39 explicit resource management
ECMAScript's using declaration (Stage 3) is the same idea at the language level. Effect's library-level semantics line up cleanly; when using ships everywhere, the two integrate naturally.
Resource safety is the #1 leak source in distributed systems. Effect shines here.
6. Effect Schema — One Definition for Runtime and Compile Time
TypeScript's limit: compile-time safety does not extend to runtime data — JSON, env, query params. zod claimed that seat. In 2026 a strong rival has emerged: Effect Schema (effect/Schema).
import { Schema as S } from "effect"
const User = S.Struct({
id: S.String,
email: S.String.pipe(S.pattern(/^[^@]+@[^@]+$/)),
age: S.Number.pipe(S.between(0, 150)),
role: S.Literal("admin", "user", "guest"),
})
type User = S.Schema.Type<typeof User> // typed automatically
const decoded = S.decodeUnknownEither(User)(jsonBlob)
// ^ Either<User, ParseError>
Differences from zod:
- Bidirectional — encode and decode fall out of one definition. Non-JSON types like
Dateare first-class. - Effect integration —
S.decode(schema)(data)returnsEffect<A, ParseError>, ready to compose with other effects. - Transformations —
S.transformbuilds "string-to-Date", "trim+lowercase", and other coercions into the schema itself. - Branded types —
S.brandgives nominal types.UserIdandOrderIdover the samestringare distinguishable.
A transform
const ISODate = S.Date.pipe(S.transform(
S.String,
{ decode: (s) => new Date(s), encode: (d) => d.toISOString() }
))
Decode at the boundary once, then carry exact types through the interior. Validation stops being scattered glue.
7. Effect Stream — Push and Pull, One Model
Bulk data, back-pressure, infinite streams — pain in Promise/AsyncIterator land. Effect Stream is pull-based at heart but absorbs push sources naturally.
import { Stream } from "effect"
const numbers = Stream.range(1, 1_000_000)
const program = numbers.pipe(
Stream.map((n) => n * 2),
Stream.filter((n) => n % 3 === 0),
Stream.take(100),
Stream.run(Sink.collectAll),
)
Stream is lazy. Only Stream.run starts the flow. Intermediate stages are throttled by back-pressure — if a downstream can't keep up, upstream pauses.
Common patterns
Stream.fromAsyncIterable— absorb anAsyncIterator.Stream.fromQueue— push sources (webhooks, events).Stream.throttle— periodic control.Stream.broadcast— fan out to many consumers.Stream.fromSchedule— time-based emissions.
Node Streams' back-pressure model is hard because who slows down is implicit. Effect Stream makes the decision explicit, in types and semantics.
8. Fibers and Structured Concurrency
Effect's concurrency primitive is the Fiber — light, co-operative, with cancellation that propagates. Not threads. Not Web Workers. Closer to green threads.
const a = Effect.delay(longFetch(...), "100 millis")
const b = longCompute(...)
// Run both, await both
const both = Effect.all([a, b], { concurrency: 2 })
// First wins — the loser is cancelled automatically
const fastest = Effect.race(a, b)
What structured concurrency means
The core promise: child lifetimes cannot exceed the parent's. If race picks a, b is interrupted immediately. Resources acquired via acquireRelease are released at the interruption point. If the parent fiber dies, every child dies with it. "A background task survived and held a resource" simply does not happen.
Cancellation propagation
const stoppable = Effect.gen(function* () {
yield* Effect.log("starting")
yield* Effect.sleep("10 seconds")
yield* Effect.log("done — never prints if cancelled")
})
const fiber = yield* Effect.fork(stoppable)
yield* Effect.sleep("1 second")
yield* Fiber.interrupt(fiber) // immediate cancellation; cleanup runs
In Promise-land, cancellation is a second-class citizen — AbortSignal covers some of the ground, but support is uneven. In Effect, every effect is interruptible and Scope guarantees cleanup.
9. Interop — Living with Promises, Callbacks, and AbortSignal
Real code talks to Promise-based APIs, callbacks, and SDKs you do not own. Effect treats those boundaries as first-class.
Promise to Effect
const fetchUser = Effect.tryPromise({
try: () => fetch(`/api/user/${id}`).then(r => r.json()),
catch: (e) => new FetchError({ cause: e }),
})
Use Effect.promise when failure cannot occur; Effect.tryPromise to map errors into your domain.
Callback to Effect
const readFile = (path: string) =>
Effect.async<Buffer, NodeJS.ErrnoException>((resume) => {
fs.readFile(path, (err, data) => {
if (err) resume(Effect.fail(err))
else resume(Effect.succeed(data))
})
})
Effect.async handles single-shot callbacks; Effect.asyncEffect handles callbacks that need cleanup.
Bridging AbortSignal
const withAbort = (signal: AbortSignal) =>
Effect.async<Data, FetchError>((resume) => {
const c = new AbortController()
signal.addEventListener("abort", () => c.abort())
fetch(url, { signal: c.signal })
.then(r => resume(Effect.succeed(r)))
.catch(e => resume(Effect.fail(new FetchError({ cause: e }))))
})
Effect-to-Promise is just Effect.runPromise. Effect is never an isolated island — keep the edge Promise/callback-shaped, the interior all-Effect.
10. From fp-ts to Effect-TS — Why the Merger Matters
The Effect-TS story is a small but meaningful event in TypeScript's functional scene.
- 2017– fp-ts (Giulio Canti) — Haskell/PureScript type classes ported to TypeScript. ADTs, monads, lenses. Academic in flavour, loved nonetheless.
- 2020– Effect-TS (Michael Arnaldi and others) — a new approach, heavily ZIO-influenced. Single
Effecttype at the centre, production-shaped from day one. - 2023 Giulio Canti joins Effect-TS. fp-ts effectively enters maintenance mode. Effect-TS is the de-facto successor.
- 2024 Effect 3.0. API stabilises. Package layout consolidates around the
effectsingle-package model. - 2026 3.x stable. Schema, Stream, Match, Worker, CLI satellites mature.
fp-ts is not broken — it still works. But for new code, Effect-TS is the obvious choice. The same people, the docs, the tooling, the adoption, and the ecosystem all live there now.
Real adoption
- Bun — parts of its tooling internals use Effect (visible in public code).
- Disney Streaming, Vercel team members, and others have spoken at conferences about Effect in production.
- A growing pile of OSS — Schema as a zod alternative, backend frameworks built on Effect.
When a TypeScript backend grows past a certain size and "manageable complexity" matters, Effect tends to show up.
11. Comparing — Promise, Result, ZIO, Rust Result
Effect is not the only answer. Other tools fit other shapes.
Plain Promise
- Pros: standard, everyone knows it, fine for small.
- Cons: error type is
unknown; environment and cancellation are not first-class. - When: one-function modules, small scripts, library boundaries.
Result / Either (neverthrow, ts-belt, fp-ts Either)
- Pros: typed errors, lightweight.
- Cons: concurrency, cancellation, and DI still need other tools.
- When: you want explicit errors but not the rest. Often a stepping stone toward Effect.
ZIO (Scala)
- Pros: Effect-TS's spiritual sibling, older ecosystem.
- Language: Scala only. Effect is essentially ZIO for TypeScript.
Rust Result + lifetimes
- Pros: compiler-enforced errors, resources, and ownership.
- Language: different paradigm, distant from JS infra.
F# Computation Expressions / Haskell IO
- Pros: deeper type systems with end-to-end effect tracking.
- Language: same seat, other tribes. Effect-TS is the spirit applied to TS.
A one-liner
| Scenario | Pick |
|---|---|
| Short script, single module | Plain Promise |
| Want typed errors and nothing else | Result library |
| Large backend, refactor safety, DI, concurrency coherence | Effect-TS |
| Scala ecosystem | ZIO |
| Systems programming | Rust Result + lifetimes |
12. When Effect Is Right — and When It's Too Much
Effect is not free. Learning curve, runtime overhead, team alignment — all costs.
Effect fits when
- Backend is medium-to-large with a multi-year lifetime.
- Refactor safety is business-critical (payments, integrity, IAP).
- Testability matters — DI / Layer reduce unit-test friction.
- Resource management is complex (files, connections, locks).
- Concurrency and cancellation come up a lot.
- The team knows or is willing to learn the FP / effect paradigm.
Don't reach for it when
- A single CLI, simple script, or prototype is what you ship.
- Team is junior and the domain is unstable.
- The code lives entirely inside React components — use it at the edge, but rolling it everywhere is a burden.
- You are a library author — forcing Effect on consumers is a hard sell.
Signal: "Our code is becoming a quilt of try/catch and closure-captured clients; a single refactor takes a week" — Effect's neighbourhood. "200-line script, runs once a minute via cron" — async/await is fine.
13. Adoption Strategy — Don't Boil the Ocean
The most common adoption failure: try to convert everything at once. You end up with a 10k-line PR, reviewers freeze, the team gets angry.
Gradual rollout
- Start at the edge — one module/service. Keep the external boundary Promise; convert only the interior.
- Schema first — adopt Effect Schema alone (no DI, no Stream). Validation gets cleaner with low buy-in.
- Result stage — first move to
Either/Resultfor typed errors, then upgrade to Effect. - Layer per service — extract core services into layers. Test doubles fall out for free.
- New code only — leave legacy alone, write new modules in Effect.
Team learning
- First 2 weeks: read Effect types fluently.
- Next 2 weeks:
Effect.genand core operations. - Then: error categories, Layer, Scope.
- Last: Stream, Fiber.
Functional libraries always feel magical at first. Effect dialled most of the magic down by giving readable shapes, but it is still a paradigm shift.
14. Mini Example — The Payment Pipeline Rewritten
Back to the opening snippet, but in Effect.
import { Effect, Layer, Data, Context, Schema as S } from "effect"
class UserNotFound extends Data.TaggedError("UserNotFound")<{ id: string }> {}
class PaymentDeclined extends Data.TaggedError("PaymentDeclined")<{ code: number }> {}
class DbService extends Context.Tag("DbService")<DbService, {
readonly getUser: (id: string) => Effect.Effect<User, UserNotFound>
readonly saveReceipt: (c: Charge) => Effect.Effect<void>
}>() {}
class StripeService extends Context.Tag("StripeService")<StripeService, {
readonly getDefaultCard: (id: string) => Effect.Effect<Card>
readonly charge: (card: Card, amt: number) => Effect.Effect<Charge, PaymentDeclined>
}>() {}
class MailerService extends Context.Tag("MailerService")<MailerService, {
readonly sendReceipt: (to: string, c: Charge) => Effect.Effect<void>
}>() {}
const chargeCustomer = (userId: string, amount: number) =>
Effect.gen(function* () {
const db = yield* DbService
const stripe = yield* StripeService
const mailer = yield* MailerService
const user = yield* db.getUser(userId)
const card = yield* stripe.getDefaultCard(user.stripeId)
const charge = yield* stripe.charge(card, amount)
yield* db.saveReceipt(charge)
yield* mailer.sendReceipt(user.email, charge)
return toReceipt(charge)
})
// Inferred signature:
// Effect<Receipt,
// UserNotFound | PaymentDeclined,
// DbService | StripeService | MailerService>
A reader of the signature already knows:
- What comes back:
Receipt. - What can fail:
UserNotFoundorPaymentDeclined— those, and only those. - What is required:
DbService,StripeService,MailerService.
Testing is a matter of plugging in mock layers. Adding a new failure lights up the compiler. Adding a new dependency changes the signature. That is the value of effect tracking.
Epilogue — Effects Make Types Say More
Effect-TS in one sentence: types say more. A function communicates not only what it returns but what may fail, what it needs, and how it cancels — all in the signature.
Just as async/await did not replace callbacks but layered richer semantics on top, Effect doesn't replace async/await — it sits above with richer semantics. Some of that ends up in the language eventually (using is one step). Effect is the library that gets you there now.
If five years into a backend you can't tell what a function may throw or require without reading the source — that's where Effect earns its keep. If you're writing a 200-line script — it doesn't.
12-item checklist
- Are domain errors defined as
Data.TaggedError? - Do function signatures honestly show the error union?
- Are you not mixing domain failures and defects in
try/catch? - Are services split into
Context.Tag+Layer? - Are test layers separate from live layers?
- Are resources acquired via
acquireRelease/scoped? - Does concurrent work have an explicit
concurrencycap? - After a
race, is loser cleanup verified? - Is external data validated by Effect Schema at one boundary?
- Are streams kept lazy until
Stream.run? - Do Promise/callback bridges use
tryPromise/async? - Is your adoption plan incremental rather than big-bang?
Ten anti-patterns
- Collapsing domain failures into a single
Error. - Catching defects in
try/catchand hiding real bugs. - No DI — importing clients at the module top, blocking tests.
- Opening files/connections without
acquireRelease. - Omitting concurrency caps in
Effect.all, drowning downstream. - Assuming
racelosers go uncleaned — they don't, but verify your release logic anyway. - Forcing streams to be eager.
- Migrating a large codebase in a single PR.
- Library authors forcing Effect on consumers.
- Reaching for Effect in a 200-line CLI to justify the learning curve.
Next post candidates
Possible follow-ups: Effect Schema deep dive — diffs from zod, transforms, branded types, Back-pressure pipelines with Effect Stream, Bootstrapping a microservice with Effect Layer — config, logging, tracing, observability.
"When types say more, refactors get less scary. Effect is that promise."
— Effect-TS deep dive, fin.
참고 / References
- Effect — Official site
- Effect Documentation
- Effect GitHub — Effect-TS/effect
- Effect Schema — Documentation
- Effect Stream — Documentation
- Effect — Why Effect
- fp-ts — Original library
- fp-ts GitHub — gcanti/fp-ts
- Effect 3.0 announcement
- Giulio Canti joins Effect — announcement
- ZIO — Scala effect system
- neverthrow — Result for TypeScript
- ts-belt — Functional helpers
- zod — schema validation
- TC39 Explicit Resource Management proposal
- Effect Days conference
- Bun runtime
- Vercel engineering blog
- Michael Arnaldi — Effect talks
- ReScript Result and effect community
- Haskell IO monad
- Rust ? operator and Result
- F# Computation Expressions
- Effect Schema vs zod — community discussion
- Structured concurrency — Wikipedia