null대신{ type: 'closed' }라는 "아무것도 아닌 객체"를 만들어서,
?.??if (!x)없이 모든 상태를 동일하게 다룬다.
API 요청 결과를 상태로 관리한다고 하자.
성공하면 데이터가 있고, 아직 요청 전이면 null인 구조이다.
type Result =
| { type: 'success'; data: User[]; updatedAt: number }
| { type: 'error'; message: string; code: number }
| null; // ← 아직 요청 전
const [result, setResult] = useState<Result>(null);
이 순간부터 result를 쓸 때마다 null 체크가 필요하다.
// result가 null일 수 있으니 ?.로 접근
const type = result?.type;
const isSuccess = result?.type === 'success';
// null이면 기본값
const message = result?.type === 'error' ? result.message : '';
// 이전 상태에서 전환할 때도 null 체크
setResult((prev) => {
if (!prev) return null; // prev가 null이면 아무것도 못 함
if (prev.type === 'success') return { ...prev, updatedAt: Date.now() };
return prev;
});
한두 군데면 괜찮지만, 이 상태를 여러 hook과 컴포넌트에서 참조하면
모든 곳에 ?.와 ??가 퍼진다.
팝업의 열림/닫힘처럼 단순 boolean으로 처리할 수 있는 상태라면 null이어도 상관없다.
하지만 상태에 데이터가 딸려오는 경우 — 성공/에러 데이터, 좌표, 대상 ID 등 —
null은 "데이터가 아직 없다"와 "비활성이다"를 구분하지 못하고,
소비하는 모든 곳에 방어 코드를 강제한다.
핵심 아이디어는 간단하다.
null 자리에 { type: 'idle' }처럼 명시적인 비활성 객체를 넣는다.
Before: success | error | null
After: success | error | idle
type Result = Success | Error | null;
// ^^^^ 객체가 아니라 "없음"
const result: Result = null;
result?.type // undefined — ?. 필요
result?.data // undefined — ?. 필요
type Result = Success | Error | Idle;
// ^^^^ 이것도 객체
const result: Result = { type: 'idle' };
result.type // 'idle' — 안전하게 접근
result.data // 컴파일 에러! — Idle에는 data가 없다고 알려줌
result가 항상 객체이므로 .type에 바로 접근할 수 있다.
그리고 data 같은 Success 전용 프로퍼티에 접근하면,
TypeScript가 "Idle에는 그 프로퍼티가 없다"고 컴파일 타임에 알려준다.
// namespace 안에는 타입만 둔다
export namespace ContextMenuPopover {
type ContextMenu = { type: 'contextMenu'; targetNodeId: string; position: Point };
type LinkPopup = { type: 'linkPopup'; targetNodeId: string; position: Point };
type Closed = { type: 'closed' }; // ← null 대신
type State = ContextMenu | LinkPopup | Closed;
type OpenState = ContextMenu | LinkPopup; // ← 열린 상태만 추출
}
State에는 세 가지 경우가 있고, 어떤 경우든 반드시 type 프로퍼티를 가진 객체이다.
// 모듈 스코프에 런타임 값을 둔다 (namespace 밖)
const POPOVER_CLOSED: ContextMenuPopover.Closed = { type: 'closed' } as const;
const isPopoverOpen = (
state: ContextMenuPopover.State
): state is ContextMenuPopover.OpenState => state.type !== 'closed';
POPOVER_CLOSED — 매번 { type: 'closed' } 객체를 새로 만들지 않기 위한 상수isPopoverOpen() — "열려 있는가?"를 물어보는 타입 가드const activePopover: ContextMenuPopover.State = /* ... */;
// 호출 전: activePopover는 ContextMenu | LinkPopup | Closed 중 하나
if (isPopoverOpen(activePopover)) {
// 호출 후: TypeScript가 Closed를 제외하고 ContextMenu | LinkPopup으로 좁혀줌
activePopover.targetNodeId; // 안전! (ContextMenu, LinkPopup 둘 다 가지고 있음)
activePopover.position; // 안전!
}
isPopoverOpen()이 true를 반환하면, TypeScript는 Closed를 자동으로 제외한다.
이후 코드에서 targetNodeId, position에 ?. 없이 접근할 수 있다.
// Before
useState<State>(null)
// After — "닫힘"이라는 의도가 드러남
useState<State>(POPOVER_CLOSED)
// Before
setActivePopover(null);
// After
setActivePopover(POPOVER_CLOSED);
// Before — null일 수 있으니 ?.
activePopover?.type === 'contextMenu'
// After — 항상 객체이므로 직접 접근
activePopover.type === 'contextMenu'
// Before — null이면 기본값
activePopover?.position ?? { x: 0, y: 0 }
// After — 타입 가드로 안전하게
isPopoverOpen(activePopover) ? activePopover.position : { x: 0, y: 0 }
// Before — "prev가 null이면 null 반환"이라는 방어 코드
setActivePopover((prev) => {
if (!prev) return null;
return { type: 'linkPopup', targetNodeId: prev.targetNodeId, position: prev.position };
});
// After — 타입 가드의 의도가 명확
setActivePopover((prev) => {
if (!isPopoverOpen(prev)) return prev;
return { type: 'linkPopup', targetNodeId: prev.targetNodeId, position: prev.position };
});
// Before — "있으면서 invalid"를 truthy 체크로 표현
if (activePopover && !isPopoverValid) closePopover();
// After — "열려 있으면서 invalid"가 읽힌다
if (isPopoverOpen(activePopover) && !isPopoverValid) closePopover();
┌─────────────────────────────────────────────────┐
│ namespace ContextMenuPopover (타입 전용) │
│ │
│ type ContextMenu = { type: 'contextMenu', ... } │
│ type LinkPopup = { type: 'linkPopup', ... } │
│ type Closed = { type: 'closed' } │
│ type State = ContextMenu | LinkPopup | Closed │
│ type OpenState = ContextMenu | LinkPopup │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ 모듈 스코프 (런타임 값) │
│ │
│ POPOVER_CLOSED — 닫힘 상수 (내부 전용) │
│ isPopoverOpen() — 타입 가드 (export) │
└─────────────────────────────────────────────────┘
| 상황 | 추천 |
|---|---|
| 열림/닫힘 같은 단순 토글 | boolean으로 충분 |
| 상태가 "있음/없음" 두 가지뿐이고 데이터 없이 한 곳에서만 쓰임 | null로 충분 |
| 상태에 데이터가 딸려오고(좌표, ID 등) 여러 곳에서 참조 | Null Object 추천 |
| discriminated union에 "비활성" 상태가 필요 | Null Object 추천 |
?. ??가 3곳 이상에 퍼져 있음 | Null Object 추천 |
나중에 새 상태(예: 'tooltip')가 추가될 수 있음 | Null Object 추천 (switch exhaustiveness) |
{ type: 'closed' }는 "닫힘이라는 값이 있다"이다..type에 항상 접근 가능 → ?. 불필요.isPopoverOpen()으로 열린 상태를 좁히면 → 나머지 프로퍼티에도 ?. 불필요.