타입 단언, 꼭 필요할 때만
공항 보안 검색대를 생각해봅시다. 일반 승객은 짐 검사와 신분증 확인을 거쳐야 합니다. 그런데 외교관은 VIP 패스를 제시하면 검문 없이 통과합니다. 빠르고 편리합니다. 하지만 만약 그 VIP 패스를 잘못된 사람이 갖고 있다면? 위험한 물건이 검색을 통과할 수 있습니다.
타입 단언(as)은 TypeScript에서 VIP 패스와 같습니다. "이 값의 타입은 내가 직접 보장한다"고 컴파일러에게 선언하는 것입니다. 컴파일러는 그 말을 믿고 검사를 건너뜁니다. 맞게 썼다면 아무 문제가 없습니다. 틀렸다면 런타임 오류가 납니다.
타입 단언이란
as 키워드로 타입을 직접 지정합니다.
// 새 파일: type-assertion.tsconst input = document.getElementById("username");// input의 타입은 HTMLElement | null 입니다// HTMLInputElement의 .value 속성은 쓸 수 없습니다const inputElement = document.getElementById("username") as HTMLInputElement;// inputElement의 타입은 HTMLInputElement 입니다console.log(inputElement.value); // 이제 .value를 쓸 수 있습니다
document.getElementById()는 HTMLElement | null을 반환합니다. 어떤 종류의 HTML 요소인지, 아니면 없는 요소인지 알 수 없기 때문입니다. 하지만 우리는 HTML 파일에서 id="username"인 요소가 <input> 태그임을 알고 있습니다. 그 지식을 as HTMLInputElement로 표현합니다.
언제 단언이 허용되는가
TypeScript의 as는 완전히 다른 타입으로의 단언은 막아줍니다. 어느 정도 관련이 있어야 합니다.
// 가능한 단언 — 서로 포함 관계가 있습니다const value: string | number = "hello";const str = value as string; // string | number → string, 가능합니다// 불가능한 단언 — 전혀 관련이 없습니다const num = 42;const wrong = num as string; // 오류: 'number' 형식을 'string' 형식으로 변환할 수 없습니다.
string | number에서 string으로의 단언은 가능합니다. string이 string | number의 부분집합이기 때문입니다. 하지만 number를 string으로 단언하는 것은 두 타입이 완전히 겹치지 않으므로 오류가 납니다.
어떤 이유로든 강제로 단언하고 싶다면 unknown을 거치는 방법이 있습니다. 하지만 이렇게 해야 한다면, 설계에 문제가 있다는 신호일 가능성이 높습니다.
// 이중 단언 — 거의 항상 코드 냄새입니다const dangerous = (42 as unknown) as string; // 컴파일은 되지만 위험합니다
단언이 필요한 전형적인 상황
단언이 필요한 경우는 주로 TypeScript가 알 수 없는 외부 정보를 우리가 알고 있을 때입니다.
DOM 요소 가져오기
가장 흔한 사례입니다. TypeScript는 HTML 파일을 읽지 못하므로, 특정 id를 가진 요소가 어떤 종류인지 알 수 없습니다.
// 새 파일: dom-assertion.ts// 타입 단언 없이는 .value에 접근할 수 없습니다const emailInput = document.getElementById("email") as HTMLInputElement;const submitButton = document.querySelector(".submit-btn") as HTMLButtonElement;const canvas = document.getElementById("myCanvas") as HTMLCanvasElement;const ctx = canvas.getContext("2d"); // 이제 canvas가 HTMLCanvasElement임을 압니다// 버튼 클릭 이벤트submitButton.addEventListener("click", () => { const email = emailInput.value; console.log(`제출된 이메일: ${email}`);});
이런 경우 단언은 합리적입니다. 개발자는 HTML 구조를 알고 있고, id="email"이 실제로 <input> 요소임을 보장할 수 있습니다.
JSON 데이터 파싱
JSON.parse()는 any를 반환합니다. 파싱 결과의 구조를 우리가 알고 있다면 단언할 수 있습니다.
// 새 파일: json-assertion.tsinterface Config { host: string; port: number; debug: boolean;}const configJson = '{"host":"localhost","port":3000,"debug":true}';const config = JSON.parse(configJson) as Config;console.log(`${config.host}:${config.port}`); // localhost:3000
다만 이 방법도 실제로 JSON 데이터가 Config 형식인지 검증하지는 않습니다. 런타임에서 잘못된 데이터가 들어올 수 있습니다. 중요한 데이터라면 Zod 같은 런타임 검증 라이브러리를 쓰는 것이 더 안전합니다.
단언을 남용하면 위험한 이유
단언은 컴파일러의 검사를 우회합니다. 잘못 쓰면 TypeScript의 타입 안전성이 무너집니다.
// 새 파일: dangerous-assertion.tsinterface User { name: string; email: string; age: number;}// 서버에서 받은 데이터가 실제로는 불완전할 수 있습니다const serverData = { name: "이민준" }; // email, age가 없습니다const user = serverData as User; // 컴파일 오류 없음// 런타임에서 오류가 납니다console.log(user.email.toUpperCase()); // TypeError: Cannot read properties of undefinedconsole.log(user.age + 1); // NaN
컴파일러는 "알겠어요, 당신이 User라고 하면 User예요"라고 믿습니다. 그 믿음이 틀렸을 때의 책임은 개발자에게 있습니다. 런타임 오류는 컴파일 오류보다 훨씬 찾기 어렵습니다.
// 단언을 남발하는 안 좋은 예function processData(data: unknown) { const user = data as User; // 검증 없이 단언 const name = (user as any).name; // any 단언으로 모든 검사 우회 const email = (data as User).email; // 계속되는 단언 // ... TypeScript를 쓰는 의미가 없어집니다}
이렇게 as를 반복해서 쓴다면, TypeScript가 제공하는 안전망을 스스로 걷어차는 것입니다.
더 나은 대안 — 조건문으로 좁히기
단언보다 조건문으로 타입을 좁히는 것이 훨씬 안전합니다.
// 단언 대신 조건문을 사용합니다function processInput(value: string | number): string { // 단언을 쓰는 잘못된 방법 // return (value as string).toUpperCase(); // number일 때 런타임 오류 // 올바른 방법: 조건으로 확인합니다 if (typeof value === "string") { return value.toUpperCase(); } return value.toString();}
조건문을 쓰면 TypeScript가 실제로 타입을 검사하고, 틀린 분기에서 오류를 잡아줍니다. 단언은 그 검사를 건너뜁니다.
non-null 단언 연산자
as 외에 ! 연산자도 단언의 일종입니다. 값 뒤에 !를 붙이면 "이 값은 null이나 undefined가 아님을 보장한다"고 선언합니다.
// 새 파일: non-null-assertion.tsconst button = document.getElementById("btn");// button의 타입은 HTMLElement | null// non-null 단언으로 null이 아님을 보장button!.addEventListener("click", () => { console.log("클릭됨");});// 단계적으로 쓸 수도 있습니다const btn = document.getElementById("btn")!; // HTMLElement (null 제외)btn.addEventListener("click", () => {});
!도 as처럼 실제로 null이 아님을 검증하지는 않습니다. 요소가 실제로 없다면 런타임에 TypeError가 납니다. 확실히 존재한다고 보장할 수 있는 경우에만 써야 합니다.
조건 분기가 번거롭다면, 옵셔널 체이닝(?.)을 대안으로 쓸 수 있습니다.
// non-null 단언 대신 옵셔널 체이닝const button = document.getElementById("btn");button?.addEventListener("click", () => { console.log("클릭됨");});// button이 null이면 addEventListener를 호출하지 않고 undefined를 반환합니다
단언의 적절한 사용 기준
정리하면, 단언이 적절한 경우와 피해야 하는 경우는 다음과 같습니다.
단언이 적절한 경우는 TypeScript의 타입 시스템이 접근할 수 없는 외부 정보를 우리가 확실히 알 때입니다. DOM 요소의 실제 종류, 서버 응답의 보장된 구조, 또는 라이브러리의 타입 정의가 불완전할 때가 여기에 해당합니다.
단언을 피해야 하는 경우는 단순히 컴파일 오류를 없애기 위해 쓸 때입니다. "일단 as로 막으면 되겠지"라는 생각이 들었다면, 더 근본적인 문제가 있다는 신호입니다. 타입 설계를 다시 검토하거나 조건문으로 타입을 좁혀야 합니다.
// 단언 사용 전 스스로에게 질문합니다:// "왜 TypeScript가 이 타입을 추론하지 못하는가?"// "이 단언이 항상 안전한가?"// "대신 조건문으로 처리할 수 없는가?"
타입 단언은 TypeScript가 알지 못하는 정보를 우리가 직접 채워 넣는 도구입니다. VIP 패스처럼 강력하지만, 잘못 쓰면 위험합니다. TypeScript의 안전망을 충분히 활용한 뒤, 정말 필요한 경우에만 꺼내는 것이 좋습니다.
PART 05에서 TypeScript의 핵심 "지능"인 타입 좁히기를 배웠습니다. 코드를 읽고 타입을 추적하는 제어 흐름 분석, 세 가지 좁히기 도구(typeof, instanceof, 판별 유니온), 그리고 최후의 수단인 타입 단언까지 살펴봤습니다.
더 깊이 들어가고 싶다면 "실전 TypeScript" PART 03에서 사용자 정의 타입 가드(is 키워드)와 never를 활용한 완전성 검사(exhaustive check)를 배울 수 있습니다. 이 도구들을 쓰면 TypeScript의 타입 좁히기를 더 정밀하게 제어할 수 있습니다.