2 / 3
존재하면 안 되는 조합은 타입으로 막는다
사람의 주의력에 기대던 규칙은, 주의력이 없는 쪽이 코드를 쓰는 순간 무너진다.
처음
프로필 화면의 상태는 필드 세 개였다. 로딩 중인지, 데이터, 에러.
만든 사람은 꼼꼼했다. 성공하면 loading을 끄고, 실패하면 loading을 끄고 error를 넣었다. 한 번도 빠뜨린 적이 없었다.
type State = {
loading: boolean;
data?: User;
error?: Error;
};그 사이
상태를 바꾸는 곳이 늘었다. 첫 로딩, 당겨서 새로고침, 30초마다 폴링, 편집 저장 후 다시 불러오기, 다른 탭에서 로그아웃했을 때의 초기화.
다섯 군데, 다섯 명. 어떤 사람은 새로고침 중임을 표시하려고 isRefreshing을 따로 만들었다. 이제 "로딩 중"을 뜻하는 필드가 두 개다. 이 다섯 군데가 전부 같은 규칙을 지켜야 한다는 걸 아는 사람은 처음 만든 사람 하나였다.
type State = {
loading: boolean;
isRefreshing: boolean;
data?: User;
error?: Error;
lastFetchedAt?: number;
};setState({ ...state, loading: true });
const data = await fetchProfile();
setState({ ...state, loading: false, data, lastFetchedAt: Date.now() });setState({ ...state, isRefreshing: true });
await refetch();
setState({ ...state, isRefreshing: false });useEffect(() => {
const id = setInterval(refetch, 30_000);
return () => clearInterval(id);
}, []);await saveProfile(form);
setState({ ...state, loading: true });
refetch();onSignOut(() => {
setState({ loading: false, isRefreshing: false });
});3개월 뒤, 여섯 번째
"에러가 나면 재시도 버튼을 보여주세요."
새로 온 개발자는 AI에게 부탁했다. AI는 다섯 군데 중 하나를 참고해 catch 블록을 깔끔하게 다시 써줬다. 그럴듯했고, 타입 체크도 통과했다.
catch (e) {
setState({ ...state, error: e });
// loading은 true 그대로
}금요일 저녁
API 서버가 잠깐 흔들렸다. 사용자 화면에는 스피너가 계속 돌고, 그 바로 아래에 에러 메시지와 재시도 버튼이 같이 떠 있었다.
재시도를 누르면 스피너 아래에 스피너가 하나 더. 슬랙에 스크린샷이 올라왔다.
왜 이렇게 됐나
타입은 "로딩 중이면서 에러"인 상태를 허락하고 있었다. 필드가 다섯 개, 바꾸는 곳이 여섯 군데가 되면, 둘이 동시에 있으면 안 된다는 규칙을 매번 기억하는 건 사람에게도 어렵다.
AI에게는 그 기억조차 없었다. 허락된 것 중에서 가장 그럴듯한 코드를 골랐을 뿐이다.
터지기 전에
가능한 상태만 유니언으로 나열한다. 로딩 중이면서 에러인 상태는 이제 쓸 수조차 없다.
AI가 같은 실수를 해도, 이번엔 빌드가 먼저 멈춘다.
type State =
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: Error };switch (state.status) {
case "loading": return <Spinner />;
case "success": return <Profile user={state.data} />;
case "error": return <ErrorView error={state.error} />;
}