TypeScript에서 satisfies 연산자와 as 타입 단언(Type Assertion)의 차이와 올바른 사용법
Problem
TypeScript를 사용하다 보면 객체의 타입을 지정할 때 as 키워드(타입 단언)를 쓰거나 변수 선언 시 :를 통해 타입을 지정합니다. 최근 TypeScript 4.9에 도입된 satisfies 연산자를 보게 되면, 기존의 타입 지정 방식이나 as와 어떤 점이 다른지 혼란스러울 수 있습니다. 두 방식 모두 타입을 다루는 것 같지만, 언제 어떤 것을 사용해야 타입 안정성을 해치지 않으면서 원하는 결과를 얻을 수 있는지 명확히 알기 어렵습니다. 특히 객체의 구체적인 속성 값을 유지하면서도 특정 인터페이스를 따르는지 검증하고 싶을 때 딜레마에 빠지게 됩니다.
Background
타입스크립트의 주 목적은 런타임 에러를 컴파일 타임에 미리 잡아내는 것입니다. 변수에 타입을 지정하는 일반적인 방식(const obj: Type = {})은 업캐스팅을 발생시켜 객체의 구체적인 리터럴 타입 정보를 잃게 만듭니다. 반면 as 키워드를 사용하는 타입 단언은 컴파일러의 타입 검사를 강제로 무시하고 개발자의 선언을 우선시합니다. 이는 개발자가 컴파일러보다 더 많은 정보를 알고 있을 때 유용하지만, 코드가 변경되었을 때 에러를 잡아내지 못해 타입 안정성을 크게 훼손할 수 있습니다. 이러한 기존 방식들의 한계를 극복하고, 객체의 구체적인 타입 추론을 유지하면서도 특정 인터페이스를 준수하는지 안전하게 검증하기 위해 satisfies 연산자가 도입되었습니다.
Solution
두 기능은 문법적으로 비슷해 보일 수 있지만, 동작 방식과 목적이 완전히 다릅니다. 상황에 맞게 적절히 구분하여 사용해야 합니다.
1. Type Assertion (as): “컴파일러야, 내 말을 믿어”
타입 단언은 TypeScript의 타입 검사를 비활성화하는 방법입니다. 개발자가 TypeScript보다 해당 값의 타입을 더 정확히 알고 있을 때 사용합니다.
// DOM에서 요소를 가져올 때 TypeScript는 이것이 정확히 어떤 HTML 요소인지 알 수 없습니다.
// 기본적으로 Element | null 타입을 반환합니다.
const element = document.querySelector('#foo');
// 하지만 개발자는 HTML에 id가 'foo'인 div가 존재한다는 것을 확신합니다.
// 이때 as를 사용하여 컴파일러에게 "이건 HTMLDivElement가 확실해"라고 알려줍니다.
const divElement = document.querySelector('#foo') as HTMLDivElement;
단점: 만약 HTML이 변경되어 해당 요소가 <button>이 되더라도, TypeScript는 에러를 뱉지 않습니다. 런타임 에러의 원인이 될 수 있으므로 사용에 매우 주의해야 합니다.
2. The satisfies Operator: “구체적인 타입은 유지하되, 조건에 맞는지 검사해줘”
satisfies는 TypeScript가 코드를 기반으로 타입을 추론하게 두면서, 그 추론된 타입이 특정 인터페이스나 타입을 만족하는지 검사(Validate)만 합니다.
type Example = {
id: string | null;
};
// ❌ 일반적인 타입 지정 방식
const foo: Example = {
id: 'hello'
};
// foo.id는 여전히 'string | null' 타입으로 평가됩니다.
// 명백히 문자열을 넣었음에도 불구하고 null 체크를 해야 합니다.
console.log(foo.id.toUpperCase()); // Error: Object is possibly 'null'.
// ✅ satisfies 사용 방식
const bar = {
id: 'hello'
} satisfies Example;
// bar.id는 정확히 'string'으로 추론됩니다. null 체크가 필요 없습니다.
console.log(bar.id.toUpperCase()); // 정상 동작
// 동시에 타입 검사도 수행합니다.
const baz = {
ID: 'hello' // Error: 'ID' does not exist in type 'Example'. Did you mean 'id'?
} satisfies Example;
3. 복잡한 매핑에서의 satisfies 활용
유니온 타입의 모든 키를 포함하는 객체를 만들 때 satisfies가 매우 유용합니다.
type Key = 'a' | 'b';
interface MyInterface {}
interface A extends MyInterface { a: string }
interface B extends MyInterface { b: string }
// 'a'와 'b' 키를 모두 가져야 하며, 값은 MyInterface를 확장해야 합니다.
const myObj = {
a: { a: "I'm an A" },
b: { b: "I'm a B" }
} satisfies Record<Key, MyInterface>;
// myObj.a.a 에 접근할 때 타입스크립트는 해당 값이 정확히 어떤 형태인지 기억하고 있습니다.
// 만약 Key 타입에 'c'가 추가된다면, myObj 선언부에서 에러가 발생하여 업데이트를 잊지 않게 해줍니다.
Deep Dive
satisfies는 특히 라우팅 설정, 테마 객체, 다국어 지원(i18n) 키 매핑 등에서 강력한 힘을 발휘합니다. 이러한 객체들은 오타 없이 특정 구조(Record<string, string> 등)를 따라야 하면서도, 객체의 속성에 접근할 때 자동 완성과 정확한 리터럴 타입 추론이 필요하기 때문입니다. 프로덕션 환경에서는 가능한 한 as 사용을 지양해야 합니다. 외부 API 응답이나 DOM 요소 선택과 같이 TypeScript가 절대 알 수 없는 런타임 환경의 경계에서만 제한적으로 as를 사용하세요. 코드 리팩토링 시 as로 덮어둔 타입은 구조가 변경되어도 조용히 넘어가 치명적인 런타임 버그로 이어질 수 있으므로, 기존의 as를 satisfies나 타입 가드(Type Guard)로 점진적으로 교체하는 것이 좋은 베스트 프랙티스입니다.
Conclusion
요약하자면, as 타입 단언은 TypeScript의 타입 검사를 무력화하고 개발자의 판단을 강제하는 방식이며, satisfies는 객체의 구체적인 타입 추론을 살리면서도 특정 타입의 조건을 만족하는지 안전하게 검사하는 방식입니다. 따라서 컴파일러가 알 수 없는 외부 요인(DOM, API 등)이 개입되는 특수한 상황이 아니라면, 항상 satisfies를 우선적으로 사용하여 타입 안정성과 개발자 경험(자동 완성 등)을 모두 확보하는 것을 권장합니다.