타입 관련 고급 기능
1. 타입 단언과 널 처리
1.1 Non-Null 단언 연산자
JavaScript에서는 DOM 요소를 선택할 때 해당 요소가 없을 수 있어서 null을 반환할 수 있습니다. TypeScript에서는 이런 상황에서 ! 연산자를 사용하여 "이 값은 절대 null이 아닙니다"라고 컴파일러에게 알려줄 수 있습니다.
// 기본적인 예제
const $button = document.querySelector('button');
// 이 시점에서 TypeScript는 $button이 null일 수 있다고 생각합니다
// 타입스크립트는 이 경우 button을 아래와 같이 타입추론 합니다
const $button: HTMLButtonElement | null
// $button.click(); // 에러 발생!
// Non-Null 단언 사용
const $safeButton = document.querySelector('button')!;
$safeButton.click(); // 정상 작동
1.2 타입 단언(Type Assertion)
때로는 우리가 TypeScript보다 어떤 값의 타입을 더 정확히 알고 있는 경우가 있습니다. 이럴 때 as 키워드를 사용하여 타입을 명시적으로 지정할 수 있습니다. 다만 타입 단언은 매우 주의해야 하는데, 타입 단언을 할 경우 런타임과 컴파일 모두 유형 검사를 우회하게 됩니다. 따라서 타입 단언을 남발하면 타입 안전성이 떨어질 수 있습니다.
그러면 타입 단언은 언제, 왜 사용해야 할까요? 타입 단언을 사용하게 되면 사람과 컴퓨터 모두가 타입을 인식하기 좋습니다. 예를 들어, IDE 같은 경우 자동완성 기능을 제공할 때 타입 단언을 사용하면 더 정확한 정보를 제공할 수 있습니다. 사람이 읽을 때에도 타입 단언을 사용하면 코드를 더 쉽게 이해할 수 있습니다.
// 이벤트 핸들러 예제
document.querySelector('input')!.addEventListener('change', (e) => {
// e.target은 기본적으로 EventTarget 타입
const target = e.target as HTMLInputElement;
console.log(target.value); // .value 접근 가능
// as 없이 작성한다면:
// const target = e.target;
// console.log(target.value); // 에러 발생!
});
// 실제 사용 예제
interface User {
name: string;
age: number;
}
const userJSON = '{"name": "John", "age": 30}';
const user = JSON.parse(userJSON) as User;
console.log(user.name); // 타입 안전성 확보
// API 응답 데이터 예제
interface User {
id: number;
name: string;
email: string;
}
async function fetchUser(): Promise<User> {
const response = await fetch('/api/user');
const data = await response.json(); // data는 any 타입
// API 응답이 User 타입임을 알고 있을 때
const user = data as User;
console.log(user.name);
console.log(user.email);
}
// 권한 검증 예제
interface Admin {
role: 'admin';
permissions: string[];
}
interface User {
role: 'user';
level: number;
}
type Person = Admin | User;
function showInfo(person: Person): void {
if (person.role === 'admin' && 'permissions' in person) {
const admin = person as Admin;
console.log(`권한: ${admin.permissions.join(', ')}`);
} else {
const user = person as User;
console.log(`레벨: ${user.level}`);
}
}
function canDelete(person: Person): boolean {
if (person.role === 'admin' && 'permissions' in person) {
const admin = person as Admin;
return admin.permissions.includes('delete');
}
return false;
}
위 예제를 타입 가드를 활용한 코드로 리팩토링 할 수도 있습니다.
// 타입 가드 함수 정의
function isAdmin(person: Person): person is Admin {
return person.role === 'admin' && 'permissions' in person;
}
function showInfo(person: Person): void {
if (isAdmin(person)) {
// person은 자동으로 Admin 타입
console.log(`권한: ${person.permissions.join(', ')}`);
} else {
// person은 자동으로 User 타입
console.log(`레벨: ${person.level}`);
}
}
function canDelete(person: Person): boolean {
if (isAdmin(person)) {
return person.permissions.includes('delete');
}
return false;
}
💡 주의사항: 타입 단언은 필요한 경우에만 사용해야 합니다. 과도한 사용은 타입 안전성을 해칠 수 있습니다.
타입 단언 vs. 타입 가드
| 방법 | 장점 | 단점 | 타입 검증 여부 |
|---|---|---|---|
| 타입 단언(as) | 간단하고 빠름 | 런타임 안전성 보장 안 됨 | 타입 검증을 우회함 |
| 타입 가드 | 재사용 가능, 명확함 | 추가 함수 작성 필요 | 실제로 타입을 검증함 |
2. 객체 타입 고급 기능
2.1 인덱스 시그니처
객체의 속성 이름을 미리 알 수 없을 때 유용한 기능입니다. 주로 동적 데이터를 다룰 때 사용됩니다.
// 기본 예제
interface Dictionary {
[key: string]: string;
}
const colors: Dictionary = {
red: "#ff0000",
blue: "#0000ff",
green: "#00ff00"
};
// 실제 활용 예제
interface UserData {
id: number;
name: string;
[key: string]: string | number | boolean; // 다양한 타입 허용
}
const userData: UserData = {
id: 1,
name: "Alice",
age: 25,
isAdmin: true,
department: "Engineering"
};
❗️주의❗️ 고정 속성의 타입은 인덱스 시그니처 타입과 호환되어야 합니다.
interface Wrong {
name: string;
age: number;
[key: string]: number; // 오류! name은 string인데 number여야 함
}
interface Correct {
name: string;
age: number;
[key: string]: string | number; // OK
}
인덱스 시그니처는 동적인 속성 이름을 처리할 수 있고 유연한 객체 구조를 정의할 수 있다는 장점이 있지만, 타입 안정성이 약해지고 오타를 잡아내기 어렵다는 단점이 있습니다.
인덱스 시그니처의 대안: Record 유틸리티 타입
Record<Keys, Type>은 키와 값의 타입을 명확하게 지정할 수 있는 유틸리티 타입입니다.
// 기본 예제
type ScoresRecord = Record<string, number>;
const scores: ScoresRecord = {
Alice: 95,
Bob: 87,
Charlie: 92
};
Record의 강력한 장점은 특정 키를 지정할 수 있다는 점입니다.
// 권한 시스템 예제
type UserRole = 'admin' | 'editor' | 'viewer';
type RolePermissions = Record<UserRole, string[]>;
const permissions: RolePermissions = {
admin: ['read', 'write', 'delete'],
editor: ['read', 'write'],
viewer: ['read']
// 세 가지 역할 모두 정의해야 함!
};
// 다국어 번역 예제
type Language = 'ko' | 'en' | 'ja';
type Translations = Record<Language, {
greeting: string;
farewell: string;
}>;
const messages: Translations = {
ko: {
greeting: '안녕하세요',
farewell: '안녕히 가세요'
},
en: {
greeting: 'Hello',
farewell: 'Goodbye'
},
ja: {
greeting: 'こんにちは',
farewell: 'さようなら'
}
// 세 언어 모두 필수!
};
2.2 keyof 연산자
객체의 모든 키를 타입으로 추출할 때 사용합니다. 특히 제네릭과 함께 사용할 때 유용합니다. 이 연산자를 사용하면 객체의 키를 문자열로 추출할 수 있습니다. 간단한 예제부터 살펴보도록 하겠습니다.
interface Product {
id: number;
name: string;
price: number;
category: string;
}
// Product의 모든 키를 타입으로 추출
type ProductKeys = keyof Product; // "id" | "name" | "price" | "category"
const data: ProductKeys = "id";
// 여기에 "name", "price", "category" 등 다른 값을 사용할 수 있습니다.
// 다만 1000, "hello" 등의 key가 아닌 경우에는 사용할 수 없습니다.
console.log(data); // id
이는 아래와 같은 방식으로 사용할 수 있게 해줍니다.
interface Product {
id: number;
name: string;
price: number;
category: string;
}
// Product의 모든 키를 타입으로 추출
type ProductKeys = keyof Product; // "id" | "name" | "price" | "category"
// 실제 활용 예제
function getProductValue(product: Product, key: keyof Product) {
return product[key];
}
const laptop: Product = {
id: 1,
name: "노트북",
price: 1000000,
category: "전자기기"
};
console.log(getProductValue(laptop, "name")); // "노트북"
console.log(getProductValue(laptop, "invalid")); // 컴파일 에러!
다만 이렇게 사용했을 경우 일반적으로 laptop["name"]과 같이 직접 접근하는 것이 더 간단할 수 있습니다. 하지만 keyof 연산자는 복잡한 타입에서 사용할 때 유용합니다.
3. 연습문제
-
Non-Null 단언 문제
// TODO: Non-Null 단언을 사용하여 아래 코드를 완성하세요. const $input = document.querySelector('input'); // $input의 value를 콘솔에 출력하세요. -
타입 단언 문제
// TODO: 아래 JSON 데이터를 파싱하고 타입 단언을 사용하여 // name과 age를 안전하게 접근할 수 있도록 만드세요. const data = '{"name": "Jane", "age": 25}'; -
인덱스 시그니처 문제
// TODO: 학생의 과목별 성적을 저장할 수 있는 인터페이스를 만드세요 // 학생의 이름은 필수이며, 나머지 과목들은 동적으로 추가될 수 있습니다 -
Record 유틸리티 타입 문제
// TODO: 카페의 요일별 영업시간을 관리하는 타입과 객체를 작성하세요. // 요일: '월' | '화' | '수' | '목' | '금' | '토' | '일' // 각 요일마다 open(오픈 시간)과 close(마감 시간) 속성 필요 // 모든 요일의 영업시간이 반드시 정의되어야 함 type Weekday = // ??? type BusinessHours = // Record를 사용하세요 const cafeHours: BusinessHours = { // 작성하세요 }; -
keyof 연산자 문제
interface Car { brand: string; model: string; year: number; } // TODO: Car 인터페이스의 속성 중 하나를 인자로 받아 // 해당 속성의 값을 반환하는 함수를 작성하세요.
4. 연습문제 해답
-
Non-Null 단언 해답
const $input = document.querySelector('input')!; console.log($input.value); -
타입 단언 해답
interface Person { name: string; age: number; } const data = '{"name": "Jane", "age": 25}'; const person = JSON.parse(data) as Person; console.log(person.name, person.age); -
인덱스 시그니처 해답
interface StudentGrades { name: string; [subject: string]: string | number; // 과목명: 점수 } const studentGrades: StudentGrades = { name: "김철수", math: 95, english: 88, science: 92 }; -
Record 유틸리티 타입 해답
// 타입 정의 type Weekday = '월' | '화' | '수' | '목' | '금' | '토' | '일'; type BusinessHours = Record<Weekday, { open: string; close: string; }>; // 객체 작성 const cafeHours: BusinessHours = { 월: { open: '09:00', close: '22:00' }, 화: { open: '09:00', close: '22:00' }, 수: { open: '09:00', close: '22:00' }, 목: { open: '09:00', close: '22:00' }, 금: { open: '09:00', close: '23:00' }, 토: { open: '10:00', close: '23:00' }, 일: { open: '10:00', close: '21:00' } }; // 사용 예시 console.log(cafeHours.월); // { open: '09:00', close: '22:00' } console.log(cafeHours.토.open); // '10:00' -
keyof 연산자 해답
interface Car { brand: string; model: string; year: number; } function getCarProperty(car: Car, property: keyof Car) { return car[property]; } const myCar: Car = { brand: "Cats Motors", model: "Licat1", year: 2224 }; console.log(getCarProperty(myCar, "brand")); // "Cats Motors" console.log(myCar['brand']); // "Cats Motors" // 간단한 타입의 경우에는 myCar['brand']와 같이 직접 접근해도 크게 문제가 없지만 // 복잡한 타입의 경우에는 keyof를 사용하는 것이 안전합니다.