넓은 타입(string, unknown, 유니온)을 특정 타입으로 안전하게 좁히는 방법들. 공통 목표는 as로 우기지 않고, 런타임 검증을 타입에 연결하는 것.
그러려면 먼저 as가 정확히 뭘 하는지 알아야 한다.
이 글에서 다루는 것:
as(타입 단언) — 왜 좁히기가 필요한가is 타입 가드in 연산자 좁히기asserts)satisfiesas — 타입 단언, 그리고 왜 좁히기가 필요한가as는 타입 단언(type assertion)이다. 컴파일러에게 "이 값의 타입은 이거야, 내가 책임진다"라고 우기는 것.
const value: unknown = "hello";
const str = value as string; // "얘 string이야" 하고 단언
as는 런타임에 아무것도 하지 않는다as는 검사를 하는 게 아니라 검사를 건너뛴다. 트랜스파일되면 흔적도 없이 사라진다. 그래서 단언이 틀려도 컴파일러는 잡아주지 못한다.
const value: unknown = 123; // 실제로는 숫자
const str = value as string; // 컴파일 통과 (거짓말인데도)
str.toUpperCase(); // 💥 런타임 에러 — 컴파일러는 침묵
이게 좁히기 패턴들이 존재하는 이유다. as는 타입 시스템의 비상구지 일상 도구가 아니다.
모든 as가 위험한 건 아니다. 방향이 위험을 가른다.
// ❌ 위험: 넓은 타입 → 좁은 타입으로 우기기 (검증을 건너뜀)
const cat = someString as Category;
// someString이 진짜 Category("food" 등)인지 아무도 확인 안 함
// ✅ 상대적으로 안전: 좁은 타입 → 넓은 타입으로 취급 (정보를 버릴 뿐)
const arr = categories as readonly string[];
// Category[]를 string[]로 취급 — 없는 값을 있다고 우기는 게 아님
string은 사실 Category야" → 틀릴 수 있고, 틀려도 안 잡힘. 위험.Category[]를 그냥 string[]로 봐줘" → 타입 정보를 버릴 뿐 거짓말은 아님. 대체로 안전.앞으로 나올 includes() 예제에서 (CATEGORIES as readonly string[])가 자주 등장하는데, 이건 후자(안전한 방향)라서 남겨두는 것이다.
as를 정당하게 쓰는 경우as가 항상 잘못은 아니다. 이럴 땐 정당하다:
includes()나 타입 가드로 확인한 값을 좁힐 때 (이게 뒤에 나올 패턴들의 핵심)document.getElementById("x") as HTMLInputElement처럼 마크업을 보고 타입을 아는 경우반대로 검증 없이 넓은 걸 좁게 우기는 as는 피해야 하고, 그 자리엔 거의 항상 더 나은 패턴(is, in, discriminated union...)이 있다.
as에서 꼭 알아야 하는 것as는 런타임 검증이 아니다. 컴파일러를 설득할 뿐 실제 타입은 안 바뀐다.as가 필요해지는 순간이 곧 "더 나은 패턴이 있다"는 신호다.as나 안전한 방향의 as는 정당하다. "as = 무조건 악"이 아니라 "검증 없는 좁히기 as"가 악이다.참고 —
as의 사촌들
as any: 검사를 통째로 끈다. 가장 위험. 웬만하면 쓰지 말 것.as unknown as T: "이중 단언".as로도 안 되는 억지 변환을 뚫을 때 쓰는데, 이게 필요하다는 건 대개 설계가 잘못됐다는 신호다.!(non-null 단언):value!는value as NonNullable<...>의 축약. "null 아님을 내가 보장"인데, 이것도 틀리면 런타임에 터진다.
is 타입 가드 (커스텀 가드)언제 쓰나: 어떤 값이 특정 타입인지 런타임에 검사하고, 그 결과를 타입 시스템에도 반영하고 싶을 때. includes(), 속성 존재 확인 등 직접 검증 로직을 짜야 하는 경우.
문제 상황: string을 좁은 유니온으로 좁히려는데 as로 우기게 된다.
type Category = "food" | "beverage" | "goods";
const CATEGORIES: readonly Category[] = ["food", "beverage", "goods"];
function parse(value: string): Category {
// includes가 Category만 받아서 어쩔 수 없이 as
return CATEGORIES.includes(value as Category) ? (value as Category) : "food";
}
패턴: 반환 타입을 value is Category로 선언한 함수를 만든다. true면 컴파일러가 이후 value를 Category로 좁힌다.
function isCategory(value: string): value is Category {
return (CATEGORIES as readonly string[]).includes(value);
}
function parse(value: string): Category {
return isCategory(value) ? value : "food"; // as 불필요
}
주의점: 타입 가드 내부의 검증 로직이 실제로 맞는지는 개발자 책임이다. value is Category라고 선언해놓고 내부에서 엉뚱한 검사를 하면 컴파일러는 그대로 믿는다. 가드는 "우기기"를 한 곳에 격리하고 재사용 가능하게 만드는 것이지, 검증을 마법처럼 대신해주는 게 아니다.
in 연산자 좁히기언제 쓰나: 유니온 타입에서 특정 속성의 유무로 분기하고 싶을 때. 판별 필드를 따로 두기 애매한 객체 유니온에 적합.
문제 상황: 두 객체 타입이 섞여 있는데, 있는 속성이 서로 다르다.
type Fish = { swim: () => void };
type Bird = { fly: () => void };
function move(animal: Fish | Bird) {
animal.swim(); // ❌ Bird엔 swim이 없음
}
패턴: in으로 속성 존재를 확인하면 그 분기 안에서 타입이 좁혀진다.
function move(animal: Fish | Bird) {
if ("swim" in animal) {
animal.swim(); // ✅ Fish로 좁혀짐
} else {
animal.fly(); // ✅ Bird로 좁혀짐
}
}
주의점: 속성 이름 오타를 잡아주지 못한다("swm" in animal은 그냥 false). 구조가 복잡해지면 다음의 discriminated union이 더 안전하다.
언제 쓰나: 여러 형태의 값을 다루는데, 각각을 명확히 구분하는 공통 리터럴 필드를 둘 수 있을 때. API 응답, 상태 관리, 이벤트 처리의 표준 패턴.
문제 상황: 성공/실패 응답을 하나의 타입으로 다루면서 안전하게 분기하고 싶다.
패턴: 공통 판별 필드(status, type, kind 등)를 리터럴로 두고 유니온을 만든다.
type ApiResult =
| { status: "success"; data: string[] }
| { status: "error"; message: string };
function handle(result: ApiResult) {
if (result.status === "success") {
result.data; // ✅ data 접근 가능
} else {
result.message; // ✅ message 접근 가능
}
}
주의점: 판별 필드는 반드시 리터럴 타입이어야 한다(status: string이면 좁혀지지 않음). never를 활용한 exhaustive check를 곁들이면 분기 누락도 컴파일 타임에 잡을 수 있다.
function handle(result: ApiResult) {
switch (result.status) {
case "success": return result.data;
case "error": return result.message;
default:
const _exhaustive: never = result; // 새 케이스 추가 시 여기서 에러
return _exhaustive;
}
}
asserts value is T)언제 쓰나: 조건이 안 맞으면 아예 진행을 멈춰야(throw) 할 때. 가드가 boolean을 리턴한다면, assertion function은 통과하지 못하면 예외를 던진다. 입력 검증, 불변식(invariant) 체크에 적합.
문제 상황: null일 수 있는 값을 매번 if (x === null) throw ...로 검사하는 게 반복된다.
패턴: 반환 타입을 asserts value is T로 선언한다. 이 함수를 통과하면 이후 코드에서 타입이 좁혀진다.
function assertDefined<T>(value: T): asserts value is NonNullable<T> {
if (value === null || value === undefined) {
throw new Error("값이 없습니다");
}
}
function useUser(user: User | null) {
assertDefined(user);
user.name; // ✅ 여기서 user는 User (null 제거됨)
}
타입 가드와의 차이:
타입 가드 (is) | assertion function (asserts) | |
|---|---|---|
| 반환 | boolean | 없음(void), 실패 시 throw |
| 사용 | if (isX(v)) { ... } | assertX(v); // 이후 좁혀짐 |
| 용도 | 분기 처리 | 통과 강제 / 조기 실패 |
주의점: assertion function은 반환 타입 애노테이션이 없으면 동작하지 않는다. function assertDefined(value): asserts value is ...처럼 명시적으로 써야 한다. 화살표 함수에 타입 추론으로는 안 된다.
satisfies — as의 건강한 대안언제 쓰나: 객체/값이 특정 타입을 만족하는지 검사하고 싶지만, 추론된 좁은 타입은 그대로 유지하고 싶을 때. as를 쓰고 싶어지는 상황의 대부분이 사실 이것.
문제 상황: as로 타입을 맞추면 구체적 정보가 뭉개진다.
type Category = "food" | "beverage" | "goods";
const labels = {
food: "식품",
beverage: "음료",
} as Record<Category, string>;
labels.food; // string (리터럴 손실)
// goods 누락됐는데 as는 통과시킴 ❌
패턴: satisfies는 타입 만족 여부만 검사하고, 실제 타입은 좁게 유지한다.
const labels = {
food: "식품",
beverage: "음료",
goods: "잡화",
} satisfies Record<Category, string>;
labels.food; // "식품" (리터럴 유지)
// goods 빠뜨리면 → 컴파일 에러 ✅
핵심 차이: as는 "이 타입이라고 쳐(검증 없이, 타입 넓힘)", satisfies는 "이 타입 맞는지 검사해줘(추론은 유지)".
주의점: satisfies는 TypeScript 4.9+에서만 쓸 수 있다.
| 상황 | 패턴 |
|---|---|
string을 좁은 유니온으로 검증하며 좁히기 | is 타입 가드 |
| 속성 유무로 유니온 분기 | in 연산자 |
| 여러 형태의 값을 판별 필드로 분기 | discriminated union |
| 조건 안 맞으면 throw하고 이후 좁히기 | assertion function |
| 객체가 타입 맞는지 검사하되 타입은 유지 | satisfies |
좁히기의 관통 원칙: 런타임 검증과 타입 시스템을 연결하라. as는 이 둘을 끊어버리기 때문에 최후의 수단이다.
다음 편: 타입 조작 — as const, keyof typeof, 유틸리티 타입, 템플릿 리터럴