타입 단언보다 타입 가드를 먼저 써야 하는 이유
타입 단언보다 타입 가드를 먼저 써야 하는 이유
타입 가드는 조건 검사를 통과한 분기에서 타입을 좁힌다. 경계에서 unknown을 받아 필요한 필드를 검증하고 내부에는 검증된 타입만 전달한다.
본문의 코드는 특정 저장소 구현을 복사하지 않고 개념을 설명하기 위해 재구성한 예시다. 이름·경로·수치는 실제 운영 정보와 무관하다.
목차
- #왜 이 문제가 생기는가
- #타입 단언은 런타임 값을 검사하지 않는다
- #기본 타입 가드의 범위
- #사용자 정의 타입 가드도 거짓말할 수 있다
- #assertion function으로 경계를 만들기
- #단언이 합리적인 좁은 지점
- #결론
- #관련 노트
왜 이 문제가 생기는가
as 키워드는 값이 실제로 해당 형태인지 검사하지 않고 컴파일러에게 믿으라고 지시한다. API 응답이나 사용자 입력에 단언을 사용하면 오류가 런타임의 더 먼 위치에서 발생한다.
타입 단언은 런타임 값을 검사하지 않는다
서버 응답을 바로 단언하면 개발 도구에서는 편해 보인다.
type Profile = {
id: string;
displayName: string;
};
const response = await fetch("/api/profile");
const profile = (await response.json()) as Profile;
console.log(profile.displayName.toUpperCase());
서버가 { id: 1, name: null }을 반환해도 as Profile은 아무 검사도 하지 않는다. 오류는 toUpperCase를 호출한 먼 위치에서 발생한다.
더 강한 이중 단언은 거의 모든 타입 관계를 우회할 수 있다.
const profile = rawValue as unknown as Profile;
이 문법이 필요하다는 것은 보통 실제 데이터와 타입 선언 사이에 해결되지 않은 간격이 있다는 신호다.
value as number는 문자열을 숫자로 바꾸지 않는다. 런타임 변환에는 Number(value)와 유효성 검사가 필요하다.
기본 타입 가드의 범위
TypeScript는 JavaScript 조건문을 따라 타입을 좁힌다.
function normalize(value: string | string[] | null) {
if (value === null) return [];
if (Array.isArray(value)) return value.map((item) => item.trim());
return [value.trim()];
}
자주 쓰는 가드는 다음과 같다.
| 문법 | 확인하는 것 | 주의점 |
|---|---|---|
typeof |
원시 타입과 function | typeof null은 object |
Array.isArray |
배열 여부 | 원소 타입은 별도 확인 |
instanceof |
prototype chain | 다른 realm과 직렬화 데이터 주의 |
key in object |
prototype 포함 프로퍼티 존재 | 값 타입까지 보장하지 않음 |
Object.hasOwn |
자체 프로퍼티 존재 | 역시 값 검증 필요 |
| 리터럴 비교 | 판별 유니온 | 외부 값은 먼저 object 검사 |
in 연산자만으로 필드 타입까지 알 수는 없다.
function hasId(value: unknown): value is { id: string } {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
typeof value.id === "string"
);
}
사용자 정의 타입 가드도 거짓말할 수 있다
반환 타입에 value is User라고 적었다고 구현이 자동으로 검증되는 것은 아니다.
type User = { id: string; email: string };
function isUser(value: unknown): value is User {
return true; // 컴파일러는 구현의 진실성을 검증하지 못한다.
}
타입 가드는 작은 런타임 validator다. 필드가 추가되었는데 guard를 수정하지 않으면 다시 거짓말이 된다. 테스트와 스키마 도구가 필요한 이유다.
describe("isUser", () => {
test("rejects a user without an email", () => {
expect(isUser({ id: "u-1" })).toBe(false);
});
test("accepts a complete user", () => {
expect(isUser({ id: "u-1", email: "a@example.test" })).toBe(true);
});
});
복잡한 중첩 객체는 직접 guard를 반복하기보다 런타임 schema에서 타입을 추론하는 방법을 검토할 수 있다.
assertion function으로 경계를 만들기
검증 실패 시 예외를 던지는 함수는 assertion signature로 이후 타입을 좁힐 수 있다.
function assertProfile(value: unknown): asserts value is Profile {
if (typeof value !== "object" || value === null) {
throw new Error("profile must be an object");
}
const row = value as Record<string, unknown>;
if (typeof row.id !== "string") {
throw new Error("profile.id must be a string");
}
if (typeof row.displayName !== "string") {
throw new Error("profile.displayName must be a string");
}
}
const body: unknown = await response.json();
assertProfile(body);
// 이 아래에서 body는 Profile이다.
renderProfile(body);
boolean guard는 분기로 처리할 때, assertion function은 잘못된 입력을 즉시 예외로 중단할 때 자연스럽다.
단언이 합리적인 좁은 지점
DOM selector처럼 코드 밖의 HTML 구조를 개발자가 보장하는 경우에는 단언을 제한적으로 사용할 수 있다.
const form = document.querySelector("#signup-form");
if (!(form instanceof HTMLFormElement)) {
throw new Error("signup form was not found");
}
form.addEventListener("submit", handleSubmit);
이 경우에도 as HTMLFormElement보다 런타임 검사가 오류 위치를 더 분명하게 만든다. 테스트 fixture나 빌드 시 생성된 코드처럼 다른 도구가 형태를 보장한다면 그 계약을 주석과 타입 생성 과정에 남긴다.
flowchart LR
A[unknown 외부 값] --> B{작은 객체인가}
B -->|yes| C[직접 type guard]
B -->|no| D[schema parser]
C --> E[검증된 도메인 타입]
D --> E
E --> F[내부 로직]결론
타입 단언은 컴파일러의 판단만 바꾸고 런타임 값을 검사하지 않지만, 타입 가드와 assertion function은 실제 조건을 통과한 값만 내부 타입으로 좁힐 수 있다. 외부 입력은 unknown으로 받고 경계에서 검증하며, 사용자 정의 guard 자체도 잘못 작성될 수 있으므로 양성·음성·경계 테스트를 둔다. 반복되는 as는 편의 코드가 아니라 누락된 모델링이나 검증 경계의 신호로 봐야 한다.