타입 있는 에러
타입 있는 에러로 실패하는 일직선 코드를 작성하세요. Kotlin Arrow 2.x의 접근 방식을 Dart로 이식했습니다.
Either ·
either & Raise 스코프 ·
nullable ·
NonEmptyList ·
에러 누적 ·
Either × 파이프라인
Kotlin Arrow에서 Dart로
Kotlin Arrow의 either { } 블록은 실패할 수 있는 단계들의
연쇄를 일직선 코드로 바꿔 줍니다 — 각 .bind()는 성공 값을
풀어내거나, 실패와 함께 블록 전체를 단락시킵니다:
// Kotlin Arrow
fun getResult(): Either<Failure, SuccessData> = either {
val user = findUser(userId).bind()
val order = findOrder(user.id).bind()
val total = calculateTotal(order).bind()
SuccessData(user, order, total)
}
FxDart는 Dart에서 같은 모양을 제공합니다:
// FxDart
Either<Failure, SuccessData> getResult() => either((r) {
final user = r.bind(findUser(userId));
final order = r.bind(findOrder(user.id));
final total = r.bind(calculateTotal(order));
return SuccessData(user, order, total);
});
둘 다, 이렇게 쓸 수밖에 없었던 중첩 flatMap 피라미드를
대체합니다:
// 이것을 대체합니다
Either<Failure, SuccessData> getResult() =>
findUser(userId).flatMap((user) =>
findOrder(user.id).flatMap((order) =>
calculateTotal(order).map((total) =>
SuccessData(user, order, total))));
Kotlin과의 차이 두 가지는 Dart의 현실입니다. Dart에는 람다 리시버가
없으므로 스코프가 명시적 매개변수(r)로 전달되고,
inline이 없으므로 비동기는 별도의 빌더
(eitherAsync)를 사용합니다. 내부 구현은 flatMap 연쇄가
아닙니다. Arrow와 마찬가지로, 실패한 r.bind는
스코프 토큰이 달린 비공개 신호를 던지고 빌더가 경계에서 잡아냅니다 —
그래서 이른 반환, 반복문, if가 블록 안에서 전부 그대로
동작하고, 중첩된 빌더가 서로의 에러를 가로채는 일도 없습니다.
스코프 어휘
모든 것이 빌더가 건네주는 r에 달려 있습니다 —
r.을 입력하면 전부 발견할 수 있습니다:
Either<String, int> parsePort(String raw) => either((r) {
final n = r.ensureNotNull(int.tryParse(raw), () => '"$raw"는 숫자가 아닙니다');
r.ensure(n > 0 && n < 65536, () => '$n은 범위를 벗어났습니다');
return n;
});
switch (parsePort('8080')) {
case Right(:final value): print('$value 포트에서 대기 중');
case Left(:final value): print('잘못된 설정: $value');
}
r.bind(either)/r.bindAll(eithers)— 풀어내거나 단락합니다.r.ensure(cond, () => err)— 타입 있는 에러 버전의require.r.ensureNotNull(x, () => err)— non-null을 반환하며 타입 승격이 일어납니다.r.recover(block, onRaise)— 중첩 스코프에서 raise된 에러를 처리합니다.r.withError(transform, block)— 다른 에러 타입을 이 스코프에 맞게 변환합니다.r.raise(err)— 곧바로 단락합니다.Never를 반환합니다.
eitherAsync는 비동기 쌍둥이이고(raise는 같은 await 체인
안에서만), nullable/nullableAsync는
Either 대신 T?를 돌려주는 nullable 우선
쌍둥이입니다 — FxDart는 nullable 우선이므로 Option 타입은
없습니다.
깊이 알아보기: either &
Raise 스코프 → ·
깊이 알아보기: nullable →
첫 실패만이 아니라 모든 실패를 모으기
검증에는 첫 번째 에러가 아니라 모든 에러가 필요합니다. 별도의
Validated 타입을 대체하는 Arrow의 방식입니다:
final user = either<Nel<String>, User>((r) => r.accumulate((acc) {
final name = acc.accumulating((r) => validateName(r, input));
final age = acc.accumulating((r) => validateAge(r, input));
return User(name.value, age.value); // 모든 에러가 한 번에 보고됩니다
}));
r.accumulate는 모든 분기를 실행하고 실패 전부를
NonEmptyList(Nel)로 이어 붙입니다 — 비어 있을
수 없는 제로 비용 확장 타입입니다. 고정 인자 편의 함수
r.zipOrAccumulate2..5가 흔한 경우를 담당하고,
r.mapOrAccumulate(items, transform)는 컬렉션 전체를
fail-slow로 검증합니다. r.bindNel은 한 분기가 여러 에러를
한꺼번에 보태게 해 주고, someEither.toEitherNel()은
fail-fast 값을 누적 스코프로 이어 줍니다.
깊이 알아보기: 에러 누적 → ·
깊이 알아보기: NonEmptyList →
파이프라인과의 융합
Arrow에도, 다른 Dart FP 라이브러리에도 없는 부분입니다. 타입 있는 에러가 FxDart의 지연·동시성 파이프라인과 융합됩니다.
// 레코드 500건을 한 번에 8건씩 검증하고, 모든 실패를 순서대로 보존합니다.
final result = await fxStream(records)
.mapOrAccumulate<String, User>((r, rec) async {
final parsed = r.ensureNotNull(tryParse(rec), () => '잘못된 레코드: $rec');
return await enrich(parsed);
}, concurrency: 8);
rights(), lefts(), separated(),
sequence()(fail-fast — 첫 Left에서 상류
당기기를 멈춤), mapOrAccumulate()(fail-slow)는
fx()/비동기 체인의 즉시 실행 종결 연산자입니다. 동시성 검증은
FxDart의 다른 기능과 똑같이 concurrent(n) 역채널 위에서
동작하며, 각 원소는 자기만의 스코프에서 실행되므로 한 원소의 실패가 다른
원소로 새어 나갈 수 없습니다.
예외 vs raise된 에러
경계는 단호합니다. raise된 에러는 도메인의 타입 있는 실패이고,
throw된 예외는 결함이므로 either를 그대로 뚫고
전파됩니다. throw를 Either로 붙잡고 싶다면 명시적으로
쓰세요:
final parsed = Either.catching(() => jsonDecode(raw)); // Either<Object, dynamic>
final typed = Either.catchingWith(ParseFailure.new, () => jsonDecode(raw));
깊이 알아보기: Either.catching은
Either 페이지에 있습니다 →
toList()로 구체화하거나
위의 즉시 실행 종결 연산자를 사용하세요. 지연된 raise는
RaiseLeakedError로 요란하게 실패합니다. (2) raise 블록 안에서
맨몸 catch를 쓰지 마세요 — 단락 신호를 항상 통과시키는
catching/catchingAsync를 사용하세요
(신호는 Error이므로 on Exception은 이미
안전합니다).
왜 이 페이지의 이름이 타입 있는 에러일까요? Monad 같은 함수형 프로그래밍 용어를 쓰지 않고 말이죠. 이름에 담긴 이유 →