타입 좁히기(Narrowing)와 가드(Guard)
공식문서 기반의 타입스크립트 입문기
들어가며
1-1편에서 validatePayload 함수로 unknown을 좁히는 방법을 배웠지만, API 응답처럼 복잡한 유니온 타입을 다룰 때는 더 정교한 타입 가드가 필요합니다. response.status === "success"만으로는 TypeScript가 response.data.products의 타입을 보장해주지 않습니다.
이때 필요한 건 response is SomeType을 반환하는 타입 가드 함수입니다. 이를 통해 unknown에서 특정 유니온 멤버로 안전하게 좁힐 수 있습니다.
TypeScript의 제안
unknown을 Extract와 type guard로 특정 유니온 멤버로 좁히는 전략을 소개합니다. 핵심은 response is SomeType을 반환하면 조건문 내부에서 자동으로 타입이 좁혀진다는 점입니다.
타입 가드로 유니온 타입 좁히기
type Product = { id: number; name: string; price: number };
type ApiResponse =
| { status: "success"; data: { products: Product[] } }
| { status: "error"; errorCode: number }
| { status: "loading" };
// 1-1편의 validatePayload와 같은 원리로 작동
function isSuccess(response: unknown): response is ApiResponse & { status: "success" } {
return (
typeof response === "object" &&
response !== null &&
"status" in response &&
(response as ApiResponse).status === "success"
);
}
async function renderDashboard() {
const response = await fetch("/api/dashboard").then((res) => res.json());
if (isSuccess(response)) {
// response는 이제 { status: "success"; data: { products: Product[] } }로 좁혀짐
return renderProducts(response.data.products);
}
return renderError("응답 형식이 올바르지 않습니다");
}response is ApiResponse & { status: "success" } 반환 타입 덕분에, isSuccess(response)가 true면 TypeScript는 response를 성공 상태로 인식합니다. 1-1편의 validatePayload가 객체의 모든 프로퍼티를 한 번에 검증했다면, 여기서는 유니온 타입의 특정 멤버를 식별하는 데 초점을 맞춥니다.
심층 분석
1) 기본 타입 좁히기: typeof와 리터럴 비교
function processValue(value: unknown) {
if (typeof value === "string") {
return value.trim().toUpperCase();
}
if (typeof value === "number") {
return value.toFixed(2);
}
if (value === "success") {
return "성공";
}
return "알 수 없는 값";
}각 조건문은 value를 더 구체적인 타입으로 좁히며, 리터럴 비교를 추가하면 더욱 결정을 확정할 수 있습니다.
typeof 분기 사이에는 사람이 읽는 순서대로 “더 구체적인 타입을 먼저, 넓은 타입은 나중”으로 두면 컨텍스트를 변경하지 않고 확정할 수 있습니다. 마지막 return은 모든 guard를 통과하지 못한 예외 경로로 남겨 두어, unknown 상태에서 의도하지 않은 값이 흘러나오는 것을 방지합니다.
2) 객체 구조 검증: in과 instanceof
function processResponse(response: unknown) {
if (typeof response === "object" && response !== null) {
if ("data" in response) {
console.log("데이터 있음:", response.data);
}
if ("message" in response && typeof response.message === "string") {
console.log("메시지:", response.message);
}
}
if (response instanceof Error) {
console.log("에러 발생:", response.message);
}
}"data" in response 조건은 response가 data 속성을 가진 객체로 좁혀지며, instanceof는 런타임 클래스의 존재 여부 기반 검증에 유용합니다.
response !== null처럼 널 체크를 먼저 하는 것은 필수인데, 그렇지 않으면 in이 런타임에서 오류를 낼 수 있습니다. instanceof는 클래스의 프로토타입 체인을 따라가므로, 인터페이스처럼 런타임 정보가 없는 타입에는 사용할 수 없다는 점을 함께 안내하면 좋습니다. 필요한 경우 in이나 커스텀 타입 가드를 함께 쓰면 인터페이스 형태의 객체도 안전하게 다룰 수 있습니다.
3) 사용자 정의 타입 가드와 asserts
TypeScript는 내장된 typeof/in/instanceof 외에도, 개발자가 직접 만든 검증 로직을 타입 가드로 사용할 수 있습니다. value is Type 형태의 반환 타입을 가진 함수는 조건문에서 타입을 좁히는 데 사용되며, asserts value is Type는 함수가 성공하면 해당 값의 타입을 확정짓는 데 사용됩니다.
function isStringArray(value: unknown): value is string[] {
// 조건이 true일 때만 이 함수를 호출한 분기에서 string[]로 좁혀진다
return Array.isArray(value) && value.every((item) => typeof item === "string");
}
function assertIsStringArray(value: unknown): asserts value is string[] {
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
// 조건을 만족하지 않으면 즉시 예외를 던져 이후 흐름에서 타입을 전제로 삼을 수 있게 함
throw new Error("문자열 배열이 아닙니다");
}
}
function processData(data: unknown) {
if (isStringArray(data)) {
// 이 분기에서는 true로 판별되었기에 data가 string[]으로 좁혀짐
return data.map((s) => s.toUpperCase());
}
// 검증에 실패하면 assertIsStringArray가 예외를 던짐
assertIsStringArray(data);
// assert 함수가 예외 없이 끝났으므로 여기선 data가 string[]으로 확정됨
return data;
}isStringArray는 조건문에서 true가 될 때만 그 브랜치 안에서 data를 좁혀 줍니다. 함수가 false를 반환하면 다른 분기로 들어가거나 예외를 던지는 로직을 따로 두어야 합니다. 타입스크립트의 흐름 분석은 value is Type 결과가 true인 경로에서만 좁힌 타입을 기억하므로, 짧고 명시적인 조건으로 좁히는 데 적합한 편입니다.
asserts value is Type 형태의 함수는 값이 타입을 만족하지 않으면 예외를 던지고, 만족하면 아무것도 반환하지 않습니다. 따라서 assertIsStringArray(data)가 아무런 예외 없이 끝났다는 사실만으로 이후 코드 전체에서 data를 string[]으로 다룰 수 있습니다. asserts 함수는 여러 분기에서 공통 검증을 묶어 호출하고자 할 때, 허용되지 않는 상태일 경우 즉시 실패하게 하며, 그 이후에는 타입 안정성을 강제하기에 도음이 됩니다.
참고: 공식 문서의 Narrowing with Type Guards and Differentiating Types 에는 이 두 방식의 차이를 “type predicate은 Boolean 결과에 따라 좁힘, asserts는 반환 이후에 타입 보장”으로 설명합니다.
isStringArray와 assertIsStringArray는 목적이 겹치지만 유인 점이 다릅니다. 앞자는 조건문 안에서 부분적인 확인을 하며, 뒤자는 그 조건을 만족하지 않을 때 코드 실행을 중단해서 나머지 흐름이 조건을 전제로 할 수 있게 만드는 타입 안전 장치입니다.
4) 단계적 타입 좁히기 전략
function safelyProcessApiResponse(response: unknown) {
if (typeof response !== "object" || response === null) {
throw new Error("객체가 아닙니다");
}
if (!("status" in response)) {
throw new Error("status 속성이 없습니다");
}
if (isSuccess(response)) {
return processSuccessData(response.data);
}
if (isError(response)) {
return processErrorData(response.errorCode);
}
throw new Error("알 수 없는 응답 형식");
}typeof → 속성 확인 → 타입 가드 → 에러 처리 순으로 흐름을 구성하면 unknown을 안전하게 success 또는 error 상태로 좁힐 수 있습니다.
실전 패턴 (In React)
type DashboardResponse = ApiResponse;
function ProductDashboard() {
const [result, setResult] = useState<DashboardResponse>({ status: "loading" });
useEffect(() => {
fetch("/api/dashboard")
.then((res) => res.json())
.then((data: unknown) => {
if (isSuccess(data)) {
setResult(data);
} else if (isError(data)) {
setResult(data);
} else {
setResult({ status: "error", errorCode: 400 });
}
})
.catch(() => {
setResult({ status: "error", errorCode: 500 });
});
}, []);
if (result.status === "loading") {
return <Spinner />;
}
if (result.status === "error") {
return <ErrorPanel code={result.errorCode} />;
}
return <ProductList data={result.data.products} />;
}타입 가드로 검증한 unknown 데이터를 setResult에 넣으면, TypeScript는 각 분기에서 해당 상태의 속성만 안전하게 취급하도록 제한합니다.
함정
as를 남발하면 진짜 검증을 우회해 버리므로, 조건문에서 자연스럽게 좁히는 흐름을 먼저 고민하세요.typeof null === "object"이므로in앞에서 반드시response !== null을 체크해야 합니다.asserts함수는false일 때 예외를 던지거나never를 반환해야 타입이 좁혀집니다.instanceof는 런타임 클래스가 있어야 작동하므로, 인터페이스에는in을 사용하세요.
예상 질문
Q1. as로 타입을 강제하면 안 되나요?
as는 “나는 더 좁게 알고 있다”는 단언일 뿐입니다. 대신 타입 가드로 조건을 하나씩 좁혀 나가면, 실제 런타임 검증이 함께 이뤄집니다.
Q2. guard를 비동기 코드 안에서 쓰면 복잡하지 않나요?
await 뒤에 바로 guard를 쓰면 됩니다. try/catch에 섞어 “예외는 실패”라는 흐름으로 만들면 제어가 명료해집니다.
async function handleEvent() {
const data = await fetchEvent().then((res) => res.json());
if (!isEventPayload(data)) {
throw new Error("이벤트 페이로드가 아닙니다");
}
processPayload(data);
}isEventPayload를 await 직후에 사용하면 try 안의 이후 로직이 곧바로 좁혀진 타입으로 취급됩니다.
Q3. React에서 guard를 재사용하려면?
isSuccess 같은 guard를 utils로 빼면 useEffect, 이벤트 핸들러, 렌더링에서 모두 같은 기준을 공유할 수 있습니다.
// utils/guards.ts
export function isFormState(value: unknown): value is FormState {
return typeof value === "object" && value !== null && "status" in value;
}
// Component.tsx
useEffect(() => {
if (isFormState(prevState)) {
setForm(prevState);
}
}, [prevState]);공통 guard를 모듈로 뽑아두면 컴포넌트, 훅, 유틸리티 어디서나 동일한 조건을 따를 수 있습니다.
요약
타입 좁히기는 type으로 분기한 결과를 실제 API 응답과 맞춰보는 행위입니다. typeof, in, instanceof, 사용자 정의 guard, asserts를 순차적으로 사용하면 unknown이 안전한 상태로 내려가고, React에서는 그 안에서만 필드를 사용하게 만들어 런타임 오류를 줄일 수 있습니다.