# Error Types — 에러를 타입으로 닫는다

적용: 실패할 수 있는 모든 코드 (API·저장·비동기 UI).

- **Result 타입이 기본**: `{ ok: true; data: T } | { ok: false; error: E }`. try-catch+`instanceof` 흩뿌리기 금지.
- **Result는 `shared/lib/result.ts`에 하나만 둔다.** 처음 필요해진 도메인 슬라이스 안에 선언하면 두 번째 도메인이 재사용할 경로가 없다(entities↔entities import는 FSD가 막는다) — 도메인마다 `{ok,data}`·`{ok,value}`·`{success,error}`로 갈라지고 공용 헬퍼를 영영 못 쓴다. 실패 코드(`'storage-unavailable'` 같은 E)는 도메인에 남긴다.
- **실패 원인을 화면까지 살려 보낸다.** union으로 원인을 구분해 놓고 전역 토스트 한 문구로 뭉개면 타입만 정확하고 사용자는 여전히 모른다 — 특히 "영영 성공하지 않는 실패"(프라이빗 모드·용량 초과)에 "잠시 후 다시 시도해주세요"는 거짓말이다. 원인별로 안내하려면 `mutationOptions`에 `meta: { ignoreToast: true }`를 주고 컴포넌트 `onError`에서 `error`를 분기한다.
- **`mutationFn`은 실패하면 반드시 throw한다.** 저장소·API 경계가 돌려준 `Result`를 받아 `ok`가 아니면 던져라. `{ ok: false }`로 resolve하면 TanStack Query는 그걸 **성공으로 판정**해서 `isError`가 false로 남고 `onError`가 실행되지 않는다 — 낙관적 갱신 롤백과 전역 실패 처리가 통째로 죽는데 화면은 성공한 것처럼 보인다. Result는 경계에서 원인을 실어 나르는 그릇이고, mutation의 성공·실패 신호는 throw다.
- **throw하는 외부 세계(fetch·SDK)는 `shared/api` 경계에서 Result로 변환**한다. pages·widgets·features 안에 try-catch가 보이면 경계가 샌 것이다. (현 템플릿의 ky `ApiError` 변환이 그 경계다)
- **서버 null도 같은 경계에서 소멸**: zod `.nullable().transform((v) => v ?? undefined)`. 내부 부재 표현은 undefined 하나로 통일.
- **에러에 발생 위치(source)를 담아라**: `FormError | ApiError | NetworkError` union — 실패 경로들이 같은 문구로 뭉개지면 사용자는 뭘 고쳐야 할지 모른다. "데이터 없음"(정상 온보딩)을 에러로 오귀속하지 마라.
- **비동기 UI 상태 union에는 실패 variant가 필수**: `checking | loading | done`만 있으면 네트워크 실패 시 영원히 loading에 갇힌다(stuck).
- **스텁도 실패를 시그니처에**: 나중에 붙일 기능의 스텁이라도 `addUser(): void`가 아니라 `Result<void, 'email-taken'>` — 시그니처가 실패 처리를 미리 강제한다.
