
타입스크립트로 프로젝트를 여러 번 진행하면서, 생각보다 섬세하고 예민한 언어라는 느낌을 받았다. 특히 서버에서 전달받은 데이터의 타입을 정의하고 검증하는 과정에서 그 섬세함이 두드러졌다.
이렇게 "모든 속성에 대해 타입을 정의해야 한다"는 점이 다소 부담스러웠는데, 이를 효과적으로 줄여주는 도구가 바로 유틸리티 타입이다.
오늘은 타입스크립트의 유틸리티 타입에 대해 알아보고 정리해보려고 한다.
타입스크립트는 공통 타입 변환을 용이하게 하기 위해 몇 가지 유틸리티 타입을 제공하는데 이런 유틸리티들은 전역에서 사용 가능하다.
서버에서 데이터를 받아올 때, 응답 데이터의 구조가 복잡하거나 재사용해야 할 경우, 매번 새로운 타입을 정의하는 것은 비효율적이다. 이런 상황에서 유틸리티 타입을 사용하면, 기존 타입을 재활용하거나 쉽게 변형할 수 있어서 작업량을 줄 일 수 있다.
예를 들면, 서버에서 받아온 데이터 중 일부 속성만 클라이언트에서 필요하다면 Pick 또는 Omit를 사용.
Pick<T, K>T에서 프로퍼티 K의 집합을 선택에 타입을 구성할 수 있다.
interface Todo {
title: string;
description: string;
completed: boolean;
};
// 클라이언트에서 title과 completed만 필요하다면
type TodoPreview = Pick<Todo, 'title' | 'completed'>;
const todo: TodoPreview = {
title: 'Clean room',
completed: false,
};
말 그대로 특정 타입에서 지정된 속성만 선택해서 새로운 타입을 만들 수 있다. TodoPreview는 title과 completed 두 속성만을 가지는 타입이 되며, Todo 타입에서 description 속성은 제외되는 것이다.
Omit<T, K>T에서 모든 프로퍼티를 선택한 다음 K를 제거한 타입을 구성한다.
interface Todo {
title: string;
description: string;
completed: boolean;
};
type TodoPreview = Omit<Todo, 'description'>;
const todo: TodoPreview = {
title: 'Clean room',
completed: false,
};
이 유틸리티는 특정 타입에서 지정된 속성을 제외하고 나머지 속성들을 가지는 새로운 타입을 만든다. 어떻게보면 Pick 유틸리티와 비슷하지만, 반대의 역할을 하는 유틸리티라고 보면될 것 같다.
정리하면
Pick유틸리티는 특정 타입에서 지정된 속성만을 선택하여 새로운 타입을 만드는 반면,
Omit유틸리티는 특정 타입에서 지정된 속성을 제외하고 나머지 속성들을 가지는 새로운 타입을 만들어준다.
타입스크립트의 예민함을 극복하려면, 서버에서 오는 데이터의 불확실성을 잘 처리할 수 있어야 한다.
예를 들어, 응답 데이터에 선택적 속성(옵셔널 프로퍼티)이 많거나, 모든 속성을 읽기 전용으로 처리해야 할 때, Partial 또는 Readonly 같은 유틸리티 타입 활용.
Partial<T><T>의 모든 프로퍼티를 선택적으로 만드는 타입을 구성하고, 이 유틸리티는 주어진 타입의 모든 하위 타입 집합을 나타내는 타입을 반환한다. 이게 무슨 말이냐 하면,
interface Todo {
title: string;
description: string;
completed: boolean;
};
// 클라이언트에서 일부만 업데이트할 경우
function updateTodo(todo: Todo, fieldsToUpdate: Partial<Todo>) {
return { ...todo, ...fieldsToUpdate };
};
const todo1 = {
title: 'organize desk',
description: 'clear clutter',
};
const todo2 = updateTodo(todo1, {
description: 'throw out trash',
});
위 예제 코드를 보면 updateTodo 함수는 todo 객체와 업데이트할 필드를 포함하는 fieldsToUpdate 객체를 받아서, 새로운 todo 객체를 반환하는 것이다. 이때, fieldsToUpdate 객체는 Partial<Todo> 타입으로 정의되었기 때문에 title 속성을 포함하지 않아도 된다.
따라서 updateTodo 함수는 todo 객체의 속성 중 일부만 업데이트할 수 있게 해주는 것이다.
타입 정의를 일일이 작성하면 코드가 장황해지고, 데이터 구조 변경 시 일관성을 유지하기 어려워질 수 있다. 그 때 유틸리티 타입을 사용한다면 기존 타입을 기반으로 변경사항에 쉽게 대응할 수 있다.
type ApiResponse<T> = {
success: boolean;
data: T;
};
type UserResponse = ApiResponse<User>;
// 타입 구조를 재활용하므로, 변경에도 유연하게 대응 가능
const response: UserResponse = {
success: true,
data: {
id: 1,
name: "Februaar",
email: "februaar@gmail.com",
},
};
서버 API 스펙은 프로젝트 중간에 변경되는 경우도 있다. 이에 대비하는 방법도 알아두면 좋을 것 같은데, 예를 들어 새로운 속성이 추가되거나 기존 속성이 선택적 속성으로 바뀔 수 있다.
Partial)Omit)Record)앞에서 다루지 않은 Record에 대해서 추가로 다루자면,
Record<K, T>타입 T의 프로퍼티의 집합 K로 타입을 구성한다. 이 유틸리티는 특정 키와 값을 가지는 객체 타입을 정의한다.
interface UserInfo {
username: string;
email: string;
};
type UserId = 'user1' | 'user2' | 'user3';
const users: Record<UserId, UserInfo> = {
user1: { username: 'john', email: 'john@example.com' },
user2: { username: 'jane', email: 'jane@example.com' },
user3: { username: 'alice', email: 'alice@example.com' },
};
여기서 users는 UserId 타입의 키와 UserInfo 타입의 값으로 이루어진 객체이다. 즉 UserId의 각 항목에 대해 UserInfo 타입의 정보를 가진 객체를 매핑하는 것이다. 이를 통해 각 사용자에 대한 정보를 쉽게 조회하거나 업데이트할 수 있다.
왜 유틸리티 타입을 알아야 할까?