5. 없는 값 다루기
값이 없을 수 있는 상태를 다루는 규칙을 모읍니다. 값이 없다는 사실은 임의의 기본값으로 감추지 않고 사용처까지 전달합니다. 타입이 이미 보장하는 것은 다시 검사하지 않습니다. 값이 들어오는 경계에서 한 번 검사하고, 중간 함수는 검증된 타입을 사용합니다.
5.1 ??, ||, 기본 매개변수 자리에 리터럴을 적지 않습니다
기본값의 출처를 이름으로 드러내고 누락된 데이터의 처리 기준을 유지합니다.
기본값 표현
??, || 오른쪽과 기본값 자리에서는 리터럴 대신 이미 선언된 이름을 참조합니다.
리터럴을 지역 const로 옮기거나 이유 주석을 붙이기만 해서는 규칙을 충족하지 못합니다.
| 기본값 표현 | 판정 |
|---|---|
?? "help@example.com", ?? 0, ?? [], || "-" | 위반 |
?? pagination_default_page_size처럼 선언된 상수 | 통과 |
지역 const fallback = "-"로 리터럴만 옮김 | 위반 |
| 선언된 이름 둘을 합성한 파생값 | 통과 |
(size = 10) =>, {size = 10} 같은 기본값 리터럴 | 위반 |
(size = pagination_default_page_size) => | 통과 |
삼항의 대체 리터럴 value ? value : "-", String(value ?? "") | 위반 |
없음으로 취급할 값
| 대체하려는 값 | 연산자 |
|---|---|
null, undefined만 없음으로 취급 | ?? |
0, false, 빈 문자열까지 없음으로 취급하는 계약 | || |
선언된 이름이어도 기본값의 의미가 맞아야 하며, 0, false가 유효하면 ??를 씁니다.
채우는 위치는 규칙 5.2가 정합니다.
??, ||, 기본 매개변수 자리에 리터럴을 적습니다.
const supportEmail = settings.supportEmail ?? "help@example.com";
const displayName = user.nickname || "-";
const toPageRequest = (size = 10): PageRequest => { /* … */ };
이미 선언된 이름만 가리킵니다.
const supportEmail = settings.supportEmail ?? support_email_default;
const displayName = user.nickname || empty_display_text;
const toPageRequest = (size = pagination_default_page_size): PageRequest => {
return {page: pagination_default_page, size};
};
함께 보기
- 5.2 기본값은 값이 들어오는 경계에서 한 번만 채웁니다
- 2.1 프로젝트 전반의 상수는 루트
constant폴더에 주제별 파일로 둡니다 - 2.2 소유자 전용 상수는 소유자
_constant폴더에 둡니다
5.2 기본값은 값이 들어오는 경계에서 한 번만 채웁니다
기본값이 선언 한 곳에 남아 아래쪽 코드에서 ??가 되풀이되지 않습니다.
기본값은 필요한지 먼저 확인하고, 필요하면 값이 들어오는 경계에서 한 번 채웁니다. 기본값 표현은 규칙 5.1를 따릅니다.
flowchart LR
q1{"기본값이<br>필요한가?"} -- 예 --> q2{"경계에서<br>채울 수 있는가?"} -- 예 --> r2("경계에서 한 번 채움")
q1 -- 아니요 --> r1("선택 값 그대로 소비")
q2 -- 아니요 --> r3("사용처에 ?? 로 적음")| 순서 | 판단과 처리 |
|---|---|
| 1. 기본값 없이 소비할 수 있는가 | undefined를 허용하면 items?.map(…), 선택 값 비교는 variant === "compact"로 처리합니다 |
| 2. 경계에서 채울 수 있는가 | search 파라미터 파서의 .withDefault(선언된 상수), 응답 매핑, 쿼리의 select에서 한 번 채웁니다. 아래에서는 선택 값과 ??가 남지 않습니다 |
| 3. 경계에서 처리할 수 없는가 | 사용처에 fetchProducts({pageSize: query.pageSize ?? pagination_default_page_size})처럼 적습니다 |
| 4. 파생값에 이름이 필요한가 | pageSize 대신 effectivePageSize처럼 고른 결과임을 드러냅니다. 사용 횟수보다 표현식의 의미를 기준으로 판단합니다 |
배열이 필수인 API에는 반환 계약을 바꾸지 않고 선언된 기본값을 경계에서 채웁니다. 이름을 붙일지는 규칙 3.8가 정합니다.
없어도 되는 값에 기본값을 채웁니다.
const productIds = (responseProductSearch.data.rows ?? []).map((row) => row.id);
const isCompact = (variant ?? "default") === "compact";
그대로 비교하면 기본값이 필요 없습니다.
const productIds = responseProductSearch.data.rows?.map((row) => row.id);
const isCompact = variant === "compact";
같은 기본값을 사용처마다 다시 채웁니다.
fetchProducts({pageSize: query.pageSize ?? pagination_default_page_size});
setVisibleRowCount(query.pageSize ?? pagination_default_page_size);
값이 들어오는 경계에서 한 번 채워 이후 코드에서 기본값을 반복하지 않습니다.
/**
* 상품 목록 search 파라미터. pageSize를 여기서 채우므로 화면에서는 선택 값 아님
*/
const productUrlParsers = {
pageSize: parseAsInteger.withDefault(pagination_default_page_size),
};
const [query] = useQueryStates(productUrlParsers);
fetchProducts({pageSize: query.pageSize});
setVisibleRowCount(query.pageSize);
경계에서 못 하면 쓰는 자리에 그대로 적습니다.
fetchProducts({pageSize: query.pageSize ?? pagination_default_page_size});
이름을 붙인다면 파생값임이 드러나게 짓습니다.
const effectivePageSize = query.pageSize ?? pagination_default_page_size;
fetchProducts({pageSize: effectivePageSize});
setVisibleRowCount(effectivePageSize);
함께 보기
- 5.1
??,||, 기본 매개변수 자리에 리터럴을 적지 않습니다 - 3.8 변수는 재계산을 막거나 복잡한 판정에 이름을 붙일 때만 만듭니다
- 4.3 객체는 구조분해하지 않고 체인으로 읽습니다
5.3 타입이 보장하는 것을 다시 검사하지 않습니다
불필요한 검사를 줄이고 값이 실제로 없을 수 있는 경우만 확인합니다.
다시 검사하지 않을 것
타입이 이미 보장하는 조건은 다시 검사하지 않습니다.
| 검사 대상 | 처리 |
|---|---|
string의 ?.trim(), number의 typeof, 필수 필드의 isNil | 타입이 보장하므로 제거합니다 |
string | null의 isNil | 값이 없을 수 있으므로 유지합니다 |
unknown, 외부 입력 | 규칙 1.6에 따라 검증합니다 |
| 유한 수 여부 | number는 NaN, Infinity도 포함하므로 필요한 검사를 남깁니다 |
| 배열 인덱스, 열린 키 조회 | 컴파일러 옵션과 실제 길이에 따라 값이 없을 수 있으므로 필요한 검사를 남깁니다 |
선택 필드의 생략
선택 필드의 생략과 undefined 대입은 소비 계약에 맞춥니다.
| 소비 계약 | 객체 구성 |
|---|---|
| 두 상태를 구분하지 않고 타입도 허용 | undefined를 바로 넣어 불필요한 조건부 스프레드를 줄입니다 |
in, Object.hasOwn, 객체 병합, 패치 등에서 구분 | 조건부 스프레드를 유지하고 생략이 필요한 계약을 이유 주석에 적습니다 |
exactOptionalPropertyTypes 사용 | value?: T에 undefined를 쓸 수 있는지 확인합니다 |
value?: T | undefined처럼 명시적으로 허용 | 조건부 스프레드로 바꾸지 않습니다 |
타입이 string으로 보장한 값을 다시 검사합니다.
const toRowLabel = (row: ProductRow): string => {
if (isNil(row.name)) {
return row.id;
}
return row.name.trim();
};
타입이 보장하는 조건은 다시 검사하지 않습니다.
const toRowLabel = (row: ProductRow): string => {
return row.name.trim();
};
생략과 undefined를 구분하지 않는 내부 계약에서 키를 조건부로 생략합니다.
// 이 내부 표시 계약은 stockCount의 undefined 대입을 허용하고 키 존재 여부를 읽지 않음
return {
metrics,
...(stockCount === undefined ? {} : {stockCount}),
};
생략과 같은 뜻이고 타입도 허용하면 undefined를 그대로 넣습니다.
// 이 내부 표시 계약은 stockCount의 undefined 대입을 허용하고 키 존재 여부를 읽지 않음
return {
metrics,
stockCount,
};
함께 보기
- 1.6 단언으로 넘기지 않고
unknown을 좁혀서 씁니다 - 5.1
??,||, 기본 매개변수 자리에 리터럴을 적지 않습니다 - 5.4 없을 수 있는 값은 경계에서 한 번만 검사합니다
5.4 없을 수 있는 값은 경계에서 한 번만 검사합니다
값이 들어오는 경계에서 검사해 중간 함수의 중복 검사를 줄입니다.
경계가 정한 답
값의 없음 여부는 소유자 안으로 들어오는 경계에서 한 번 검사하고, 결과를 타입으로 전달합니다.
화면의 응답 매핑, select, combine, search 스키마나 컴포넌트가 프롭을 받는 자리가 경계입니다.
| 경계가 정한 답 | 전달 타입 | 사용처 |
|---|---|---|
| 기본값이 있음 | number | 규칙 5.2에 따라 채운 값을 사용합니다 |
| 없음을 화면에 표시 | number | undefined | 중간 함수는 그대로 전달하고 렌더링 위치에서 한 번 분기합니다 |
없을 때 다른 화면을 렌더하는 분기는 필요한 표시 상태이므로 유지합니다. 판정 결과를 전달하는 방법은 규칙 4.8가 정합니다.
다시 검사가 필요한 때
| 다시 검사가 필요한가 | 기준 |
|---|---|
| 경계에서 이미 확인한 없음, 유한 수 조건 | 반복하지 않습니다 |
타입이 number라는 사실만 확인됨 | NaN, Infinity, 허용 범위까지 보장하지는 않습니다 |
| 새 계산, 외부 호출로 만든 값, 검증 후 변경, 외부 값 혼합 | 기존 보장이 적용되지 않는 조건을 해당 경계에서 확인합니다 |
| 여러 입력 경로가 각각 외부 값을 받음 | 각 경계에서 검증합니다 |
unknown은 검증 책임이 있는 경계에서 받고, 공개 입력 계약을 내부 호출 하나에 맞춰 좁히지 않습니다.
타입 좁히기는 규칙 1.6을 따릅니다.
경계가 타입을 좁히지 않아 아래 함수마다 같은 값을 다시 검사합니다.
export const toSignedTone = (value: number | null | undefined): Tone => {
if (isNil(value) || !Number.isFinite(value) || value === 0) {
return "neutral";
}
return value > 0 ? "positive" : "negative";
};export const formatSignedPercent = (value: number | null | undefined) => {
if (isNil(value) || !Number.isFinite(value)) {
return copy_empty_value_text;
}
return `${value > 0 ? "+" : ""}${value}%`;
};경계가 좁힌 number를 받아 두 함수는 자기 판정만 남깁니다.
/**
* 부호 있는 변화율의 강조 톤. 0은 중립
*/
export const toSignedTone = (value: number): Tone => {
if (value === 0) {
return "neutral";
}
return value > 0 ? "positive" : "negative";
};/**
* 부호를 붙인 변화율 표시 문자열
*/
export const formatSignedPercent = (value: number) => {
return `${value > 0 ? "+" : ""}${value}%`;
};경계인 select에서 없음과 유한 수를 한 번 검사해 타입을 좁힙니다.
// page/product-detail/pg-product-detail.tsx: 서버는 계산 전이면 null. 여기서 한 번 좁힘
const responseSummarySuspense = useSuspenseQuery({
...productDetailSummaryQueryOptions(productId),
select: (response) => ({
...response,
changeRate:
isNotNil(response.changeRate) && Number.isFinite(response.changeRate) ? response.changeRate : undefined,
}),
});
없음을 읽는 자리는 화면을 렌더하는 분기 하나입니다.
// page/product-detail/_pg-product-detail-summary.tsx: 없음을 읽는 곳은 렌더하는 분기 하나
{isNotNil(summary.changeRate) && (
<UiBadge tone={toSignedTone(summary.changeRate)}>{formatSignedPercent(summary.changeRate)}</UiBadge>
)}
함께 보기