본문으로 건너뛰기
Tech Blog

as를 쓰기 전에 한 번 더 보세요

글 복사 완료!

as는 컴파일러를 설득할 뿐이라 narrowing이나 satisfies가 더 단단해요.

·10분·

TypeScript에서 빨간 줄이 가장 보기 싫었던 자리는 어디인가요? 저는 document.getElementById("canvas") 한 줄에서 HTMLElement | null이 자꾸 발목 잡히는 자리였어요. 그래서 as HTMLCanvasElement를 붙이고 빨간 줄을 끄고 다음 줄로 넘어갔거든요. 그게 잘못된 건 아니에요. 다만 as가 정확히 무엇을 멈추고 무엇을 멈추지 않는지 모른 채로 쓰면, 나중에 런타임에서 null.getContext is not a function 같은 에러를 만나게 돼요.

as가 진짜 하는 일

as는 컴파일러에게 "이 값을 더 구체적인 타입으로 다뤄달라"고 부탁하는 문법이에요. 부탁이지 검사가 아니에요. TypeScript 공식 문서는 이걸 한 줄로 못 박아요.

"Like a type annotation, type assertions are removed by the compiler and won't affect the runtime behavior of your code." - TypeScript Handbook

타입 단언은 컴파일이 끝나면 그대로 사라져요. 런타임에는 흔적도 없거든요. as 한 줄이 통과시킨 값이 실제로 그 타입인지 아닌지는 누구도 검사하지 않아요.

as HTMLCanvasElement도 마찬가지예요. 컴파일러는 그 자리부터 변수를 HTMLCanvasElement로 다루지만, 만약 진짜 null이 흘러 들어왔다면 getContext()를 호출하는 순간 그대로 터져요. 컴파일러는 단언을 받은 자리에서 이미 손을 떼버린 거예요.

비슷하게 생긴 친구가 하나 더 있어요. 변수 뒤에 붙이는 non-null assertion !이에요. value!.toUpperCase()처럼 쓰는 그거요. 이것도 똑같이 "이 값은 null/undefined가 아니다"라고 컴파일러를 설득할 뿐, 런타임 검사는 들어가지 않아요.

컴파일러도 막지 못하는 자리

as에 약한 안전 장치가 하나 있어요. "hello" as number 같은 명백한 거짓말은 컴파일 타임에 막혀요. 두 타입이 충분히 겹치지 않으면 통과 자체를 안 시켜주거든요. 그래서 우회법이 따로 있어요.

const value = "hello" as unknown as number;

unknown이 모든 타입과 겹치는 성질을 이용해 두 번 단언하는 거예요. 공식 문서도 이 우회법을 그대로 안내해요. 정말 의도한 변환이라면 먼저 unknown으로 보내고 나서 원하는 타입으로 단언하라고요. 다시 말해 이 우회법이 등장하는 자리가 보이면, "내가 컴파일러보다 이 값에 대해 더 잘 안다"고 자신할 수 있어야 한다는 뜻이에요.

객체 리터럴 단언은 더 위험해요. typescript-eslintconsistent-type-assertions 규칙 문서가 그 함정을 정리해 두었어요.

type User = { id: number; name: string; email: string };
 
const u = {} as User;
console.log(u.email.toUpperCase());

위 코드는 통과해요. 빈 객체에 User 타입을 단언하면 컴파일러는 그 자리에서 검사를 멈춰요. email 필드가 누락된 사실은 잡히지 않거든요. 같은 자리에 변수 타입 annotation을 쓰면 어떻게 될까요.

const u: User = {};

빨간 줄이 떠요. id, name, email이 빠졌다고요. 객체 리터럴은 단언 대신 annotation이 거의 항상 더 안전한 선택이에요.

as 쓰기 전에 narrowing부터

as를 붙이고 싶어지는 순간이면, 그 전에 narrowing이 가능한지 한 번 더 봐야 해요. TypeScript는 typeof, instanceof, in, 그리고 사용자 정의 type guard로 union 타입을 좁히는 법을 잘 정리해 두었어요.

typeof x === "string" 같은 분기 안에서는 컴파일러가 자동으로 타입을 좁혀요. 클래스 인스턴스라면 x instanceof Date, 객체에 특정 프로퍼티가 있는지 확인하려면 "swim" in animal처럼요. 이건 단언이 아니라 실제 런타임 검사를 동반한 좁히기예요. 코드가 통과하면 실제로 그 타입인 게 보장돼요.

복잡한 검사 로직이라면 type guard 함수로 캡슐화할 수 있어요.

function isCanvas(el: Element | null): el is HTMLCanvasElement {
  return el instanceof HTMLCanvasElement;
}
 
const el = document.getElementById("canvas");
if (isCanvas(el)) {
  el.getContext("2d");
}

el is HTMLCanvasElement 반환 타입이 핵심이에요. 이 함수가 true를 돌려주면 컴파일러는 그 분기 안에서 elHTMLCanvasElement로 좁혀줘요. 이번엔 el이 진짜 캔버스라는 걸 instanceof가 검사한 다음에 좁히는 거니까, as처럼 비어 있는 약속이 아니에요.

annotation도 as도 아닌 satisfies

설정 객체 같은 걸 다룰 때 자주 마주치는 딜레마가 하나 있어요. 타입에 맞는지 검증은 받고 싶은데, 추론된 구체 타입은 그대로 유지하고 싶은 거예요.

"The new satisfies operator lets us validate that the type of an expression matches some type, without changing the resulting type of that expression." - TypeScript 4.9 Release Notes

값이 어떤 타입에 맞는지 검증하면서도, 그 값에서 추론된 더 구체적인 타입은 그대로 살려두는 연산자예요.

as로 같은 시도를 하면 검증이 약해지고, 변수 annotation으로 가면 추론이 widening 돼요. satisfies는 두 문제를 동시에 풀어줘요.

type Palette = Record<"red" | "green" | "blue", string | { hex: string }>;
 
const colors = {
  red: "#ff0000",
  green: { hex: "#00ff00" },
  blue: "#0000ff",
} satisfies Palette;
 
colors.green.hex.toUpperCase();

Palette 타입에 어긋나는 키가 있으면 컴파일 에러로 잡혀요. 그러면서도 colors.greenstring | { hex: string }으로 widening 되지 않고, 실제로 적은 { hex: string } 모양 그대로 추론돼요. 그래서 .hex.toUpperCase()도 자연스럽게 따라오는 거예요. 같은 자리에 as Palette를 썼다면 colors.green.hex는 union을 풀지 못해서 막혔을 거예요.

satisfies는 TypeScript 4.9에서 도입됐어요. 5.x 환경이면 그냥 쓸 수 있다고 보면 돼요.

as가 정당한 자리, 그리고 통제 도구

그래도 as가 진짜로 필요한 자리가 있어요. DOM API의 getElementById처럼 라이브러리 시그니처가 의도적으로 보수적인 경우가 대표적이에요. HTMLCanvasElement로 다루겠다는 건 코드를 작성하는 사람이 구조를 알고 내리는 결정이고, 컴파일러가 추론으로 도달할 수 없는 정보거든요. JSON.parse의 결과처럼 외부 데이터의 모양을 작성자가 보장해야 하는 경계도 비슷해요. 이 자리에서는 type guard로 검증하고 단언하는 패턴이 자연스럽고요.

조직 단위에서 as를 통제하고 싶으면 typescript-eslint의 두 규칙을 묶어서 쓰는 게 좋아요.

consistent-type-assertions는 단언 스타일을 통일하고 객체 리터럴 단언을 차단할 수 있어요. objectLiteralTypeAssertions"never"로 두면 위에서 본 {} as User 패턴 자체가 lint 에러로 잡혀요. no-unnecessary-type-assertion은 타입을 바꾸지 않는 쓸데없는 단언을 잡아내요. 3 as number 같은 군더더기, 이미 non-null인 값에 !를 또 붙이는 패턴 같은 거요.

{
  "rules": {
    "@typescript-eslint/consistent-type-assertions": [
      "error",
      { "assertionStyle": "as", "objectLiteralTypeAssertions": "never" }
    ],
    "@typescript-eslint/no-unnecessary-type-assertion": "error"
  }
}

두 규칙이 같이 켜져 있으면 새로 들어오는 단언은 정말 필요한 자리에만 남아요. 이미 잘 추론되는 값에 굳이 단언을 덧붙이는 습관도 같이 사라지고요. as를 안 쓰는 게 목표가 아니라, 안 써도 되는 자리에서 안 쓰는 게 목표예요.

빨간 줄을 끄는 가장 빠른 방법이 as인 건 맞아요. 다만 그 빨간 줄은 보통 as가 아니라 narrowing이나 satisfies로 풀어야 더 단단해져요. 모듈 해석에서 비슷한 빨간 줄이 뜨는 자리는 import가 안 되는 진짜 이유에서 따로 다뤘어요.

참고 자료

관련 글