TypeScript에서 'as' 키워드 지양하기 feat: satisfies 와 is

출처:https://www.maddhruv.dev/blog/typescript-as-type-assertion
1. 타입 단언(as)
타입 단언(as)은 TypeScript에서 타입을 명시적으로 지정하는 방법 중 하나입니다. 이 키워드는 컴파일러에게 타입을 명확하게 알려주어 타입 검사를 강화하고 오류를 줄이는 데 도움을 줍니다.
마치 타입스크립트에서 타입 단언(type assertion)은 “내가 여기서 무엇을 하고 있는지 알아”라고 TypeScript에 말하는 마법의 지팡이와 같습니다. 이는 변수 타입에 대한 TypeScript의 추측을 무시하고 개발자가 원하는 타입으로 강제로 지정할 수 있는 기능입니다.
하지만 이 키워드는 타입 검사를 강화하는 것 외에도 타입 검사를 약화시키는 효과가 있습니다.
TypeScript의 가장 큰 장점은 타입 체크를 통한 안전성 보장입니다. 하지만 as 키워드를 사용하면 이러한 안전장치를 우회하게 됩니다.
실제로 타입스크립트 + 리액트 로 개발을 하다보면 타입에러가 나면 as 키워드로 해결하는 경우가 많습니다.
하지만 이렇게 해결하면 타입 검사를 우회하게 되고, 이로 인해 런타임 오류가 발생할 수 있습니다.
as는 타입 변환을 수행하지 않습니다. 즉, 단순히 컴파일러에게 "이 값은 내가 원하는 타입입니다"라고 말하는 것이며, 실제 데이터의 형태나 값이 변경되지는 않습니다.
interface User {
id: number;
name: string;
}
const onlyUserId = { id: 1 } as User; // 실제로는 name이 없음
console.log(onlyUserId.name.toUpperCase()); // 런타임 에러: Cannot read property 'toUpperCase' of undefined위 코드에서 onlyUserId는 실제로 name 프로퍼티가 없지만, as를 사용해 User 타입으로 강제하였기 때문에 컴파일러는 이를 정상으로 판단합니다. 그러나 런타임에서는 onlyUserId.name이 undefined가 되고, 그 결과 toUpperCase() 호출 시 에러가 발생합니다.
따라서 as를 사용할 때는 실제 데이터의 구조와 강제할 타입이 일치하는지 주의해야 하며, 그렇지 않으면 런타임에서 예기치 않은 오류가 발생할 수 있습니다.
interface Order {
id: number;
items: { name: string; quantity: number }[];
}
// 실제 데이터는 items가 배열이 아니라 문자열입니다.
const orderData = { id: 1, items: "not an array" } as Order;
// orderData.items는 실제로 문자열이기 때문에 인덱스로 접근할 수 없습니다.
console.log(orderData.items[0].name); // 런타임 에러 발생: items는 문자열이므로 배열 인덱싱이 불가능위 예제에서는 orderData 객체의 items 프로퍼티가 문자열임에도 불구하고, as Order를 통해 배열 형태의 타입으로 강제 변환했습니다.
컴파일러는 이를 정상으로 판단하지만, 런타임에서는 orderData.items가 문자열이기 때문에 배열 인덱싱을 시도하면 오류가 발생합니다.
즉, as를 사용하여 타입을 강제하면 실제 데이터 구조와 타입이 불일치할 때 예기치 않은 런타임 오류가 발생할 위험이 있습니다.
2. 타입 단언(as) 대신 사용할 수 있는 방법
2.1. 타입 가드 사용하기
interface Animal {}
class Dog implements Animal {
bark() {
console.log("bark");
}
}
class Cat implements Animal {
meow() {
console.log("meow");
}
}
function makeSound(animal: Animal) {
(animal as Dog).bark();
}
makeSound(new Dog()); // 'bark'
makeSound(new Cat()); // TypeError: animal.bark is not a function위 코드에서 makeSound 함수는 매개변수를 무조건 Dog 타입으로 단언합니다. 그래서 new Cat()을 넘기면 런타임 오류가 발생합니다.
function makeSound(animal: Animal) {
if (animal instanceof Dog) {
animal.bark();
} else if (animal instanceof Cat) {
animal.meow();
}
}위 코드 처럼instanceof와 같은 타입 가드를 사용하면, 객체가 실제 어떤 클래스의 인스턴스인지 확인하여 안전하게 메서드에 접근할 수 있습니다. 이렇게 하면 타입 단언에 의한 위험을 줄이고, 코드의 안전성과 가독성을 높일 수 있습니다.
function isUser(obj: any): obj is User {
return (
typeof obj === 'object' &&
obj !== null &&
'id' in obj &&
'name' in obj &&
'email' in obj
);
}
function processUser(input: unknown) {
if (isUser(input)) {
// 이 블록 안에서 input은 User 타입으로 처리됩니다
console.log(input.email); // 안전하게 접근 가능
} else {
console.log("입력값이 User 형식이 아닙니다");
}
}타입 가드를 사용하면 코드가 다소 장황해질 수 있습니다. 하지만 이 복잡함은 외부 데이터나 불확실한 데이터 구조를 안전하게 처리하기 위해 필요한 절차입니다.
2.2. 타입 교차(Intersection Types) 활용하기
interface BasicUser {
id: number;
name: string;
}
const user1 = { id: 1, name: "John", role: "admin" } as BasicUser & { role: string };- 객체 리터럴
{ id: 1, name: "John", role: "admin" }에 대해as키워드를 사용하여 강제로 타입 단언(type assertion)을 적용합니다. 여기서는BasicUser와{ role: string }의 교차 타입을 지정합니다. - 문제점:
- 안전성: 타입 단언은 컴파일러에게 “내가 이 값이 이 타입이라고 확신한다”는 신호를 보내는 것으로, 실제 값이 지정한 타입에 부합하지 않더라도 경고를 내지 않습니다.
- 유연성 부족: 객체의 타입을 나중에 변경하거나 확장할 때, 단언을 사용하면 코드 유지보수가 어려워질 수 있습니다.
type AdminUser = BasicUser & { role: string };
const user2: AdminUser = { id: 1, name: "John", role: "admin" };BasicUser와{ role: string }를 명시적으로 결합한 새로운 타입AdminUser를 정의합니다. 그런 다음user2변수의 타입을AdminUser로 선언합니다.- 장점
- 명시적 타입 지정: 타입이 명확하게 정의되어 있어서, 해당 타입에 맞지 않는 객체가 할당되면 컴파일러가 즉시 에러를 발생시킵니다.
- 유지보수 용이: 타입을 별도로 정의해두면, 타입 변경 시 해당 타입만 수정하면 되어 가독성과 재사용성이 높아집니다.
2.3 is 활용하기
is 타입 가드 활용 예시
// 두 가지 타입의 메시지 정의
type SuccessMessage = {
type: 'success';
content: string;
duration?: number;
};
type ErrorMessage = {
type: 'error';
content: string;
errorCode: string;
};
// 메시지 타입 유니온
type Message = SuccessMessage | ErrorMessage;
// 타입 가드 함수 정의
function isSuccessMessage(message: Message): message is SuccessMessage {
return message.type === 'success';
}
// React 컴포넌트
function MessageDisplay({ message }: { message: Message }) {
// 타입 가드를 사용하여 props 접근
if (isSuccessMessage(message)) {
// 이 블록 내에서는 message가 SuccessMessage 타입으로 처리됨
return (
<div className="success-message">
<p>{message.content}</p>
{message.duration && <span>자동으로 {message.duration}초 후 사라집니다</span>}
</div>
);
} else {
// 이 블록 내에서는 message가 ErrorMessage 타입으로 처리됨
return (
<div className="error-message">
<p>{message.content}</p>
<span>에러 코드: {message.errorCode}</span>
</div>
);
}
}
// 사용 예시
function App() {
const successMsg: Message = {
type: 'success',
content: '저장되었습니다!',
duration: 3
};
const errorMsg: Message = {
type: 'error',
content: '저장에 실패했습니다.',
errorCode: 'E404'
};
return (
<div>
<MessageDisplay message={successMsg} />
<MessageDisplay message={errorMsg} />
</div>
);
}이 간단한 예제에서는 isSuccessMessage 타입 가드 함수를 사용하여 메시지 객체의 타입을 구분합니다. 함수의 반환 타입 message is SuccessMessage는 TypeScript에게 이 함수가 true를 반환하면 매개변수 message가 SuccessMessage 타입임을 알려줍니다.
이렇게 하면 조건문 내에서 TypeScript가 자동으로 올바른 타입을 추론하므로, as 키워드 없이도 타입별 속성에 안전하게 접근할 수 있습니다. 예를 들어, SuccessMessage에만 있는 duration 속성이나 ErrorMessage에만 있는 errorCode 속성을 타입 오류 없이 사용할 수 있습니다.
2.4.satisfies 활용하기
// 색상 테마 객체를 정의하는 예시
type RGB = [number, number, number];
type Color = RGB | string;
// as 사용 (지양해야 할 방법)
const theme = {
primary: "blue",
secondary: [0, 255, 0],
tertiary: "#ff0000"
} as const;
// 이 경우 theme.primary는 string 타입으로 추론되어 자동완성이 제한됨
// theme.secondary에 접근할 때 튜플 메서드를 사용할 수 없음
// satisfies 사용 (권장하는 방법)
const betterTheme = {
primary: "blue",
secondary: [0, 255, 0],
tertiary: "#ff0000"
} satisfies Record<string, Color>;
// 이제 타입 검사가 이루어지면서도 원래의 타입 정보가 유지됨
// betterTheme.primary는 "blue" 리터럴 타입으로 추론됨
// betterTheme.secondary는 [number, number, number] 튜플 타입으로 추론됨
betterTheme.secondary[1]; // 255에 안전하게 접근 가능satisfies 연산자의 주요 장점
- 타입 검증 강화
- satisfies를 사용하면 값이 주어진 타입과 호환되는지 확인할 수 있습니다. 만약 값이 타입 요구사항을 충족하지 않으면 컴파일러가 에러를 발생시켜 미리 문제를 잡아줍니다.
- 타입 단언과의 차이
- 기존의 as 키워드는 개발자가 값의 타입을 강제로 단언하는 반면, satisfies는 값의 실제 타입 추론을 유지하면서도 지정한 타입을 만족하는지 검증합니다. 즉, 단언(assertion)과 달리 실제 타입 정보가 손실되지 않습니다.
- 리터럴 타입 보존
- 객체 리터럴을 사용할 때, satisfies를 활용하면 객체의 더 구체적인 리터럴 타입 정보를 보존할 수 있습니다. 예를 들어, 특정 프로퍼티에 잘못된 값을 지정했거나 누락된 필드가 있다면, 이를 바로 확인할 수 있습니다.
satisfies 연산자는 기존의 as 타입 단언보다 더 안전하고 구체적인 타입 검증 방법을 제공하며, 객체나 값의 원래 타입 정보를 그대로 유지하면서 타입 요구사항을 검증할 수 있다는 점에서 많은 장점을 가지고 있습니다.
3. 불가피하게 'as'를 사용해야 할 때
물론, 때로는 as를 사용해야 하는 상황도 있습니다:
- DOM 요소 접근 시: TypeScript가 DOM 요소의 정확한 타입을 추론하지 못할 때
const canvas = document.getElementById('myCanvas') as HTMLCanvasElement;- 외부 라이브러리와의 통합: 타입 정의가 불완전한 외부 라이브러리 사용 시
- JSON 파싱: JSON.parse()의 결과물에 대한 타입 지정 시 이런 경우에도 가능하면 타입 가드나 다른 안전한 방법을 먼저 고려하고, as는 최후의 수단으로 사용하는 것이 좋습니다.
이처럼, 컴파일러가 타입 정보를 정확하게 추론하지 못하는 상황에서 as를 사용하면 개발자가 타입을 명시할 수 있어 편리합니다. 단, 실제 데이터의 구조가 예상과 다르면 런타임 오류로 이어질 수 있으므로 사용 시 주의가 필요합니다
댓글 로딩 중...