TypeScript Indexed Access Types와 Key Remapping: 객체 프로퍼티를 자유자재로 다루는 기술
TypeScript의 Indexed Access Types와 Key Remapping을 활용하여 객체 프로퍼티를 동적으로 조작하는 방법을 알아봅니다. API 응답 처리, 상태 관리 등 실전 적용 예제와 함께 설명합니다.
왜 객체 프로퍼티를 조작해야 할까?
프론트엔드 개발을 하다 보면, 기존 객체의 구조를 그대로 유지하면서 특정 프로퍼티의 타입만 변경하거나, 혹은 프로퍼티 키와 값을 변환해야 하는 상황을 자주 마주칩니다. 예를 들어, API 응답으로 받은 객체를 화면에 표시하기 전에 특정 값의 형식을 바꾸거나, 사용자 입력에 따라 객체의 특정 필드만 필터링해야 할 때가 그렇죠. 이때마다 수동으로 새로운 타입을 정의하고 객체를 변환하는 것은 번거롭고 오류 발생 가능성이 높습니다. TypeScript는 이러한 문제를 해결하기 위한 강력한 도구인 Indexed Access Types와 Key Remapping을 제공합니다.
Indexed Access Types: 특정 프로퍼티의 타입 가져오기
Indexed Access Types는 객체의 특정 프로퍼티에 접근하여 해당 프로퍼티의 타입을 추출하는 기능입니다. 마치 배열에서 인덱스로 값을 가져오듯, 객체에서는 키(key)를 사용하여 프로퍼티의 타입을 가져올 수 있습니다. 문법은 Type[Key] 형태로 사용합니다.
예를 들어, User 타입이 다음과 같다고 가정해 봅시다.
1type User = { 2 id: number; 3 name: string; 4 email: string; 5 isActive: boolean; 6}; 7 8// 'name' 프로퍼티의 타입을 추출 9type UserNameType = User['name']; // string 10 11// 'isActive' 프로퍼티의 타입을 추출 12type UserActiveType = User['isActive']; // boolean
여기서 UserNameType은 string이 되고, UserActiveType은 boolean이 됩니다. 이는 객체의 특정 프로퍼티 타입만 필요할 때 매우 유용합니다. 예를 들어, 폼의 유효성 검사를 위해 특정 입력 필드의 타입만 가져와서 사용해야 할 때 활용할 수 있습니다.
또한, keyof 연산자와 함께 사용하면 객체의 모든 키를 유니온 타입으로 가져온 후, 이를 다시 Indexed Access Types에 적용하여 특정 키에 해당하는 타입들을 조합할 수도 있습니다.
1type UserKeys = keyof User; // 'id' | 'name' | 'email' | 'isActive' 2 3type UserInfo = Pick<User, 'name' | 'email'>; 4 5type SpecificUserInfo = User[keyof Pick<User, 'name' | 'email'>]; // string | string (중복 제거 후 string)
Pick<User, 'name' | 'email'>은 User 타입에서 name과 email 프로퍼티만 선택한 새로운 타입을 반환합니다. keyof Pick<User, 'name' | 'email'>는 'name' | 'email'이라는 유니온 타입을 생성하고, 이를 User 타입에 적용하면 User['name'] | User['email'], 즉 string | string이 됩니다. TypeScript는 중복을 제거하므로 최종적으로 string 타입이 됩니다.
Key Remapping: 객체 프로퍼티의 키와 값을 변환하기
Indexed Access Types가 특정 프로퍼티의 타입을 추출하는 기능이라면, Key Remapping은 객체의 기존 프로퍼티를 기반으로 새로운 객체 타입을 생성하면서 키(key) 와 값(value) 을 동시에 조작할 수 있는 강력한 기능입니다. 이는 주로 Mapped Types와 함께 사용됩니다. Mapped Types는 기존 타입의 각 프로퍼티를 순회하며 새로운 타입을 만드는 방식입니다. Key Remapping은 Mapped Types의 as 절을 사용하여 키를 변환합니다.
1. 프로퍼티 키 이름 변경 (Key Renaming)
기존 객체의 프로퍼티 이름을 바꾸고 싶을 때 유용합니다.
1type OriginalObject = { 2 firstName: string; 3 lastName: string; 4 age: number; 5}; 6 7// 'firstName' -> 'first_name', 'lastName' -> 'last_name' 으로 변경 8type PascalToSnake<T> = { 9 [K in keyof T as K extends 'firstName' ? 'first_name' : K extends 'lastName' ? 'last_name' : K]: T[K]; 10}; 11 12type ConvertedObject = PascalToSnake<OriginalObject>; 13/* 14type ConvertedObject = { 15 first_name: string; 16 last_name: string; 17 age: number; 18} 19*/
여기서 K in keyof T는 OriginalObject의 각 키(firstName, lastName, age)를 순회합니다. as K extends 'firstName' ? 'first_name' : K extends 'lastName' ? 'last_name' : K 부분은 각 키 K를 조건부로 변환합니다. firstName이면 first_name으로, lastName이면 last_name으로, 그 외의 키는 그대로 사용합니다. T[K]는 해당 키의 원본 값을 그대로 유지합니다.
2. 프로퍼티 값의 타입 변경 (Value Transformation)
프로퍼티의 키는 그대로 두고 값의 타입만 변경하고 싶을 때 사용합니다. 예를 들어, 모든 문자열 값을 string | null로 바꾸거나, 모든 숫자를 number | undefined로 변경하는 등의 작업이 가능합니다.
1type Data = { 2 id: number; 3 name: string; 4 createdAt: Date; 5}; 6 7// 모든 값을 'string' 타입으로 변환 8type StringifyValues<T> = { 9 [K in keyof T]: string; 10}; 11 12type StringifiedData = StringifyValues<Data>; 13/* 14type StringifiedData = { 15 id: string; 16 name: string; 17 createdAt: string; 18} 19*/ 20 21// 특정 프로퍼티의 값만 변경 (예: 'id'는 number 그대로, 나머지는 string) 22type PartialStringify<T> = { 23 [K in keyof T]: K extends 'id' ? T[K] : string; 24}; 25 26type PartiallyStringifiedData = PartialStringify<Data>; 27/* 28type PartiallyStringifiedData = { 29 id: number; 30 name: string; 31 createdAt: string; 32} 33*/
StringifyValues<T>는 모든 프로퍼티의 값을 string으로 강제합니다. PartialStringify<T>는 id 프로퍼티는 원래 타입을 유지하고, 나머지 프로퍼티는 string으로 변경합니다.
3. 프로퍼티 필터링 (Property Filtering)
특정 조건에 맞는 프로퍼티만 남기고 나머지는 제거할 수 있습니다. keyof T 대신 keyof T as ... 구문을 사용하여 조건에 맞지 않는 키를 never 타입으로 만들어 제거하는 방식이 일반적입니다.
1type Product = { 2 id: number; 3 name: string; 4 price: number; 5 description?: string; 6 stock: number; 7}; 8 9// 'string' 타입의 프로퍼티만 필터링 10type StringProperties<T> = { 11 [K in keyof T as T[K] extends string ? K : never]: T[K]; 12}; 13 14type StringFields = StringProperties<Product>; 15/* 16type StringFields = { 17 name: string; 18 description?: string; 19} 20*/ 21 22// 'number' 타입의 프로퍼티만 필터링 23type NumberProperties<T> = { 24 [K in keyof T as T[K] extends number ? K : never]: T[K]; 25}; 26 27type NumberFields = NumberProperties<Product>; 28/* 29type NumberFields = { 30 id: number; 31 price: number; 32 stock: number; 33} 34*/
T[K] extends string ? K : never 구문을 주목하세요. 프로퍼티의 타입 T[K]가 string이면 해당 키 K를 유지하고, 그렇지 않으면 never로 만듭니다. never 타입은 어떤 값도 할당할 수 없는 타입으로, 해당 프로퍼티가 결과 타입에서 완전히 제거되도록 합니다.
4. 프로퍼티 키-값 쌍 변환 (Key-Value Transformation)
기존 객체의 키와 값을 동시에 다른 형태로 변환해야 할 때도 Key Remapping을 활용할 수 있습니다. 예를 들어, 객체의 각 프로퍼티 값을 문자열 배열로 만들고, 키는 그대로 유지하는 경우입니다.
1type Config = { 2 timeout: number; 3 retries: number; 4 url: string; 5}; 6 7// 각 프로퍼티 값을 문자열로 변환하여 새로운 객체 생성 8type StringifiedConfig<T> = { 9 [K in keyof T as `${K & string}String`]: string; 10}; 11 12type MyConfigStringified = StringifiedConfig<Config>; 13/* 14type MyConfigStringified = { 15 timeoutString: string; 16 retriesString: string; 17 urlString: string; 18} 19*/
여기서는 템플릿 리터럴 타입( ${K & string}String )을 사용하여 키 이름을 timeout에서 timeoutString으로 변경하고, 값은 string으로 고정했습니다. K & string은 K가 string 타입임을 명시적으로 나타내어 템플릿 리터럴 타입 사용을 가능하게 합니다. (만약 K가 number일 수도 있다면, K & string 처리를 해주어야 합니다.)
실전 적용 예시: API 응답 데이터 변환
API에서 받은 사용자 목록 데이터를 화면에 표시하기 전에, id는 string으로 변환하고 name은 대문자로, email은 소문자로 정제하는 시나리오를 생각해 봅시다.
1interface ApiUserResponse { 2 userId: number; 3 userName: string; 4 userEmail: string; 5 registeredAt: string; 6} 7 8type ProcessedUser = { 9 id: string; 10 name: string; 11 email: string; 12}; 13 14function processUsers(users: ApiUserResponse[]): ProcessedUser[] { 15 return users.map(user => ({ 16 id: String(user.userId), // userId를 string으로 변환 17 name: user.userName.toUpperCase(), // userName을 대문자로 18 email: user.userEmail.toLowerCase(), // userEmail을 소문자로 19 })); 20} 21 22// 위 함수를 TypeScript의 타입 조작 기능으로 더 안전하게 만들기 23 24type TransformApiUser<T> = { 25 // userId -> id, userName -> name, userEmail -> email 26 id: string; 27 name: Uppercase<T['userName']>; // userName을 대문자로 28 email: Lowercase<T['userEmail']>; // userEmail을 소문자로 29}; 30 31// `as` 절을 사용하여 키를 리매핑하고, 값은 변환 함수를 직접 적용 32// (주의: 실제로는 Uppercase, Lowercase 같은 내장 유틸리티 타입이나 사용자 정의 타입 가드가 필요) 33type TransformedUser<T extends ApiUserResponse> = { 34 [K in keyof T as K extends 'userId' ? 'id' : K extends 'userName' ? 'name' : K extends 'userEmail' ? 'email' : never]: 35 K extends 'userId' ? string : // userId는 string으로 36 K extends 'userName' ? Uppercase<T[K]> : // userName은 대문자로 37 K extends 'userEmail' ? Lowercase<T[K]> : // userEmail은 소문자로 38 never; // 그 외는 제거 39}; 40 41type ProcessedUserType = TransformedUser<ApiUserResponse>; 42/* 43type ProcessedUserType = { 44 id: string; 45 name: Uppercase<string>; // 실제로는 string으로 추론될 수 있음 46 email: Lowercase<string>; // 실제로는 string으로 추론될 수 있음 47} 48*/ 49 50// 더 간결한 변환 함수와 타입 51function processApiUsers<T extends ApiUserResponse>(users: T[]): { 52 id: string; 53 name: Uppercase<T['userName']>; 54 email: Lowercase<T['userEmail']>; 55}[] { 56 return users.map(user => ({ 57 id: String(user.userId), 58 name: user.userName.toUpperCase() as Uppercase<T['userName']>, 59 email: user.userEmail.toLowerCase() as Lowercase<T['userEmail']> 60 })); 61} 62 63// 예시 데이터 64const apiData: ApiUserResponse[] = [ 65 { userId: 1, userName: 'Alice', userEmail: 'ALICE@EXAMPLE.COM', registeredAt: '2023-01-01' }, 66 { userId: 2, userName: 'Bob', userEmail: 'bob@example.com', registeredAt: '2023-01-02' }, 67]; 68 69const processedData = processApiUsers(apiData); 70console.log(processedData); 71/* 72[ 73 { id: '1', name: 'ALICE', email: 'alice@example.com' }, 74 { id: '2', name: 'BOB', email: 'bob@example.com' } 75] 76*/ 77 78// processedData[0].name 는 'ALICE' (string 타입) 79// processedData[0].email 은 'alice@example.com' (string 타입)
위 예제에서 TransformedUser<T> 타입은 ApiUserResponse를 입력받아 userId를 id로, userName을 name으로, userEmail을 email로 변경하면서 각 값의 타입을 변환합니다. Uppercase<T[K]>와 Lowercase<T[K]> 같은 내장 유틸리티 타입을 활용하면 더 정교한 타입 조작이 가능합니다. 다만, 실제 런타임에서의 toUpperCase(), toLowerCase() 메서드 호출 결과와 정확히 일치하는 타입을 생성하는 것은 복잡할 수 있으며, 대부분의 경우 string 타입으로 충분합니다.
마무리
TypeScript의 Indexed Access Types와 Key Remapping은 객체의 구조와 타입을 동적으로 조작할 수 있게 해주는 강력한 기능입니다. 이를 통해 코드의 재사용성을 높이고, 반복적인 타입 정의 작업을 줄이며, 런타임 로직과 타입 간의 일관성을 유지하는 데 크게 기여합니다. API 응답 처리, 상태 관리, UI 컴포넌트 설계 등 다양한 프론트엔드 개발 시나리오에서 유용하게 활용될 수 있습니다.