타입스크립트로 프로젝트를 하다 보면, 기존에 정의해둔 타입을 조금만 바꿔서 쓰고 싶은 순간이 자주 찾아옵니다. 회원 정보 타입에서 비밀번호 필드만 빼고 싶을 때, 모든 필드를 선택적으로 바꿔서 수정 폼에 쓰고 싶을 때, 이미 정의된 타입에서 몇 개의 필드만 골라 목록 화면용 타입을 만들고 싶을 때 등이 대표적입니다.
이럴 때마다 새로운 타입을 처음부터 다시 정의하면 코드 중복이 생기고, 원본 타입이 바뀔 때마다 관련된 타입을 전부 찾아서 수정해야 하는 문제가 생기는데요. 이런 중복은 단순히 번거로운 정도를 넘어서, 타입과 실제 데이터가 어긋나는 버그로 이어지기 쉽습니다.
타입스크립트는 이런 상황을 위해 유틸리티 타입(Utility Types)을 기본으로 제공합니다. 기존 타입을 입력으로 받아 일부를 골라내거나, 제외하거나, 선택적으로 바꿔주는 내장 도구들입니다. 프레임워크나 별도의 라이브러리 없이 타입스크립트 자체에 포함되어 있기 때문에, 별다른 설정 없이 바로 사용할 수 있다는 점도 큰 장점입니다.
이번 글에서는 그중에서도 실무에서 가장 자주 쓰이는 대표 유틸리티 타입 다섯 가지 Partial, Required, Pick, Omit, Record에 집중해서 살펴보겠습니다.
미리 요점만 콕 집어보면?
유틸리티 타입의 필요성을 이해하려면, 먼저 타입 중복이 어떻게 발생하는지부터 살펴봐야 합니다.
실무에서 비슷한 모양의 타입을 여러 번 정의하는 경우는 생각보다 흔합니다. 예를 들어, 회원 정보를 다루는 애플리케이션을 만든다고 해봅시다. 가입 화면, 프로필 수정 화면, 관리자용 목록 화면에서 사용하는 타입이 조금씩 다를 것입니다.
interface User {
id: number;
email: string;
password: string;
name: string;
phone: string;
createdAt: Date;
}
interface UserUpdate {
email?: string;
name?: string;
phone?: string;
}
interface UserListItem {
id: number;
email: string;
name: string;
}
얼핏 보면 문제가 없어 보이지만, 이 구조에는 함정이 있습니다. User 타입에 새로운 필드(예:address)가 추가되면 UserUpdate와 UserListItem도 함께 수정해야 합니다. 수정을 빠뜨리면 원본 타입과 어긋난 상태가 되어 버그의 원인이 됩니다. 비슷한 타입이 늘어날수록 이 문제는 더 커지고, 타입 정의를 관리하는 데 쓰이는 시간도 급격히 증가합니다.

게다가 이런 타입은 대부분 원본과 의존 관계를 갖고 있습니다. UserUpdate는 "수정 가능한 필드들"이고, UserListItem은 "목록에 노출할 필드들"입니다. 정말로 독립된 타입이 아니라 User를 재료로 파생된 타입들이라는 뜻입니다. 그렇다면 이 의존 관계를 타입 시스템으로도 명시해두는 편이 훨씬 자연스럽고 안전합니다.
해결책은 간단합니다. 원본 타입 하나만 잘 정의해두고, 나머지는 그 원본을 변환해서 만드는 것입니다. "이 필드만 골라내줘", "모든 필드를 선택적으로 바꿔줘" 같은 변환 규칙만 선언해두면 원본이 바귈 때 파생 타입도 자동으로 따라옵니다.
타입스크립트는 이런 변환을 위한 내장 도구를 제공하는데, 이것이 바로 유틸리티 타입입니다. 예를 들어, 아직 자세히 다루지는 않았지만, 아래 코드를 한 번 살펴봅시다.
type UserUpdate = Partial<Pick<User, "email" | "name" | "phone">>;
type UserListItem = Pick<User, "id" | "email" | "name">;
Pick은 원본에서 특정 필드만 골라내고, Partial은 모든 필드를 선택적으로 바꿔줍니다. 이 두 줄만으로 앞서 별도의 인터페이스로 정의했던 UserUpdate와 UserListItem이 만들어집니다. User 타입의 email이 emailAddress로 바뀌더라도 관련 타입들은 컴파일러가 알아서 추적해주기 때문에, 타입 정의 한 곳만 고치면 됩니다. 이제 각 유틸리티 타입이 어떻게 동작하는지 하나씩 살펴보겠습니다.
수정 기능을 구현할 때 가장 먼저 만나게 되는 유틸리티 타입입니다. 업데이트할 필드만 전달받고 나머지는 그대로 두고 싶을 때, Partial<T>가 그 역할을 해줍니다.
Partial<T>는 이름 그대로 "부분적인" 타입을 만들어주는 유틸리티 타입입니다. 타입 T의 모든 프로퍼티를 선택적(optional)으로 바꾼 새 타입을 만들어줍니다. 필드 하나하나에 직접 ?를 붙이는 대신, 타입 수준에서 "전부 선택적으로"라는 규칙을 한 번에 적용하는 셈입니다.
interface User {
id: number;
name: string;
email: string;
phone: string;
}
// 모든 필드가 optional로 변환됨
type UserPatch = Partial<User>;
// {
// id?: number;
// name?: string;
// email?: string;
// phone?: string;
// }
여기서 한 가지 짚고 넘어갈 점이 있습니다. 실무에서는 "optional 프로퍼티"와 “undefined를 허용하는 프로퍼티"를 거의 같은 의미로 이해해도 큰 문제가 없지만, 엄밀하게는 완전히 같은 개념은 아닙니다. 특히 exactOptionalPropertyTypes 옵션을 켜면 둘 차이가 명확하게 드러나므로, ?가 붙은 필드와 field: string | undefined가 언제나 호환된다고 단정하지 않는 편이 좋습니다.
Partial의 프로필 수정 함수에서 가장 전형적으로 활용됩니다. 변경된 필드만 받아서 원본 객체에 병합하는 구조입니다.
function updateUser(user: User, patch: Partial<User>): User {
return { ...user, ...patch };
}
const current: User = {
id: 1,
name: "효빈",
email: "a@b.com",
phone: "010",
};
// name만 변경하고 싶을 때
const updated = updateUser(current, { name: "효빈2" });
만약 Partial을 쓰지 않고 patch 매개변수 타입을 그냥 User로 받았다면, 호출할 때마다 모든 필드를 채워 넣어야 했을 것입니다. 이름만 바꾸는 경우에도 email, phone, id까지 전부 명시해야 하는 불편한 API가 되는 셈입니다. Partial 덕분에 실제로 바꾸고 싶은 필드만 골라서 넘길 수 있게 된 것입니다.
다만 Partial을 사용할 때 한 가지 주의할 점이 있습니다. 모든 필드가 선택적이 된다는 말은, 빈 객체 {}도 유효한 값으로 인정된다는 뜻입니다. "아무것도 바꾸지 않는 업데이트"가 가능해지는 셈이므로, 적어도 하나의 필드는 반드시 받아야 하는 상황이라면 별도의 런타입 검증 로직이 필요합니다.
Partial이 모든 필드를 선택적으로 바꿔주는 도구였다면, Required는 정반대 방향으로 작동합니다. 기본값 병합처럼, 선택적 입력을 확정된 상태로 만드는 시점에서 활용됩니다.
Required<T>는 타입 T의 모든 선택적 프로퍼티에서 ?를 제거해, 모든 필드가 반드시 존재하도록 강제하는 타입을 만들어줍니다. 언뜻 보면 쓸 일이 있을까 싶지만, 설정 객체처럼 "기본값과 병합한 이후에는 모든 필드가 반드시 채워져 있어야 하는" 상황에서 유용합니다. 입력 시점에는 선택적이지만, 가공을 거친 이후에는 모든 값이 확정되어 있어야 한다는 흐름을 타입으로 자연스럽게 표현할 수 있습니다.
interface Config {
host?: string;
port?: number;
timeout?: number;
}
const defaultConfig: Required<Config> = {
host: "localhost",
port: 3000,
timeout: 5000,
};
function createClient(userConfig: Config) {
const config: Required<Config> = { ...defaultConfig, ...userConfig };
// 이 시점부터 config의 모든 필드는 확정된 값을 가집니다.
// 내부 구현에서는 host, port, timeout이 undefined일 가능성을 신경 쓰지 않고 바로 사용할 수 있습니다.
}
userConfig는 선택적으로 받지만, 기본값과 병합한 결과는 Required<Config>로 확정됩니다. 이렇게 되면 이후 코드에서 config.host가 undefined일 가능성을 걱정하지 않고 바로 사용할 수 있습니다. 선택성이라는 불확실성을 경계에서 한 번 제거하고 나면, 내부 구현 코드는 훨씬 단순해집니다.
이 패턴은 타입스크립트에서 자주 등장하는 "입력은 느슨하게 받고, 내부에서는 엄격하게 다룬다"는 원칙의 전형적인 예시이기도 합니다.
기존 타입에서 특정 필드만 뽑아 새 타입을 만들고 싶을 때 사용하는 유틸리티 타입입니다. 컴포넌트 props처럼, 전체 데이터 중 일부만 필요로 하는 인터페이스를 명확하게 표현할 때 특히 유용합니다.
Pick<T, K>에서 T는 원본 타입이고, K는 골라낼 프로퍼티 키의 유니온입니다. 이때 K는 반드시 keyof T에 속하는 키여야 하므로, 원본 타입에 없는 키를 적으면 컴파일 에러가 납니다. 덕분에 오타가 나더라도 타입 단계에서 바로 잡을 수 있습니다.
interface Post {
id: number;
title: string;
content: string;
author: string;
createdAt: Date;
likeCount: number;
viewCount: number;
}
type PostPreview = Pick<Post, "id" | "title" | "author" | "createdAt">;
// {
// id: number;
// title: string;
// author: string;
// createdAt: Date;
}
Pick의 장점은 타입 이름만 봐도 어떤 필드가 포함되는지 바로 알 수 있다는 것입니다. 별도로 새 인터페이스를 만들고 필드를 나열하는 것보다 훨씬 선언적입니다. 또한 원본 Post 타입의 필드 타입이 바뀌면 PostPreview도 자동으로 같이 바뀌기 때문에, 타입 동기화를 위한 수작업이 사라집니다.
반대로 Pick이 잘 맞지 않는 경우도 있습니다. 원본 타입에서 대부분의 필드를 남기고 일부만 제외하고 싶을 때는, 남길 필드를 일일이 나열해야 해서 오히려 장황해집니다. 이럴 때는 뒤에 소개할 Omit이 더 잘 어울립니다.
Pick이 "남길 것을 고르는" 방식이라면, Omit은 "뺄 것만 지정하는" 방식입니다. 빼야 할 필드가 적고 남길 필드가 많을 때, Pick 보다 훨씬 간결합니다.
Omit<T, K>는 원본 타입 T에서 K에 해당하는 프로퍼티만 빼고 나머지 전부로 새 타입을 만듭니다. Omit은 Pick과 달리 K에 keyof T 제약이 느슨하게 적용되긴 하지만, 실무에서는 원본에 존재하는 키만 지정하는 편이 의도를 명확히 드러내고 안전합니다.
게시글 생성 API의 요청 타입을 만든다고 해봅시다. 서버가 자동으로 생성하는 id, createdAt 같은 필드는 클라이언트가 보내면 안 됩니다.
interface Post {
id: number;
title: string;
content: string;
author: string;
createdAt: Date;
}
type CreatePostInput = Omit<Post, "id" | "createdAt">;
// {
// title: string;
// content: string;
// author: string;
// }
function createPost(input: CreatePostInput) {
}
만약 Pick으로 같은 일을 하려면 Pick<Post, "title" | "content" | "author"> 처럼 남기고 싶은 필드를 전부 나열해야 합니다. 필드가 많아질수록 번거로워지고, 새 필드가 추가될 때마다 이 목록도 유지보수를 해야 합니다. 반면, Omit은 "빼고 싶은 것"만 지정하면 되기 때문에, 원본 타입에 필드가 추가되면 자동으로 새 타입에도 포함됩니다.
비밀번호 같은 민감 정보를 응답에서 제외할 때도 자주 쓰입니다.
interface User {
id: number;
email: string;
password: string;
name: string;
}
type UserResponse = Omit<User, "password">;
다만 한 가지 주의할 점이 있습니다. 도메인 모델에서 요청 타입이나 응답 타입을 곧바로 파생시키는 방식은 편리하지만, 두 타입이 시간이 지나면서 서로 다른 방향으로 진화할 수도 있습니다. 이럴 땐 굳이 원본에 묶어두기보다는 독립된 타입으로 분리하는 편이 더 나을 수 있습니다. 유틸리티 타입은 강력하지만, 언제나 원본과 강하게 결합하는 것이 최선은 아니라는 점은 기억해 둘 만합니다.
Pick과 Omit 중에 무엇을 쓸지 고민된다면, 간단한 기준을 기억해두면 좋습니다. 보통 "남기고 싶은 필드"가 소수일 때는 Pick, "빼고 싶은 필드"가 소수일 때는 Omit을 선택하는 편이 직관적입니다. 두 경우 모두 코드를 읽는 사람이 한눈에 의도를 파악할 수 있도록, 타입 이름에 의도를 명확하게 드러낸다는 점이 중요합니다.
Record도 당연히 유틸리티 타입이지만, 앞선 네 가지와는 결이 조금 다릅니다. 기존 객체 타입을 변형하기보다는, 키 집합과 값 타입을 선언해 새로운 객체 구조를 만들 때 자주 쓰입니다.
Record<K, V>는 K에 해당하는 키 집합과 V에 해당하는 값 타입으로 구성된 객체 타입을 만들어줍니다. 인덱스 시그니처({ [key: string]: string })와 비슷해 보이지만 중요한 차이가 있습니다. 인덱스 시그니처는 "어떤 문자열 키든 허용한다"는 느슨한 약속이지만, Record는 키를 리터럴 유니온으로 제한할 수 있어서 허용되는 키 집합이 명확하게 고정됩니다. 지정한 키가 누락되면 컴파일 에러가 발생하고, 반대로 허용되지 않은 키를 추가하려 해도 에러가 발생합니다.
type StatusLabel = Record<"loading" | "success" | "error", string>;
// {
// loading: string;
// success: string;
// error: string;
// }
const labels: StatusLabel = {
loading: "로딩 중",
success: "성공",
error: "실패",
};
위 예시에서 error 키를 빠뜨리고 선언하면, 타입스크립트가 바로 잡아줍니다.
정해진 키 집합에 대해 모든 값을 매핑해야 하는 상황에서 Record가 유용합니다. 페이지 권한, 상태별 스타일, 언어별 문자열 같은 경우가 그렇습니다.
type Role = "admin" | "member" | "guest";
const permissions: Record<Role, string[]> = {
admin: ["read", "write", "delete"],
member: ["read", "write"],
guest: ["read"],
};
새로운 역할이 Role에 추가되면, permissions 객체에도 해당 키를 반드시 채워야 합니다. 이렇게 키 목록과 실제 데이터를 타입으로 강하게 묶어두는 것이 Record의 역할입니다. 새 키 추가와 데이터 반영이 함께 이루어지도록 타입 시스템이 강제해 주므로, 누락된 케이스로 인한 런타임 버그를 미리 차단할 수 있습니다.
유틸리티 타입의 진짜 힘은 서로 조합해서 쓸 때 드러납니다. 각 유틸리티 타입이 작은 도구라면, 조합은 이 도구들을 이어 붙여 더 정교한 변환을 만들어내는 과정이라고 생각해 볼 수 있습니다.
"일부 필드만 골라서, 그중에서도 전부 선택적으로 받고 싶다"라는 요구사항은 Partial과 Pick의 조합으로 풀립니다.
// email, phone만 선택적으로 받아서 업데이트
type ContactUpdate = Partial<Pick<User, "email" | "phone">>;
function updateContact(id: number, patch: ContactUpdate) {
// ...
}
유틸리티 타입을 중첩할 때는 안쪽부터 바깥쪽 순서로 적용된다는 점을 기억하면 도움이 됩니다.

위 예시에서 Pick<User, "email" | "phone">이 먼저 계산되어 email과 phone만 가진 타입이 만들어지고, 그 결과에 Partial이 적용되어 두 필드가 모두 선택적이 됩니다. 순서를 바꿔서 Pick<Partial<User>, "email" | "phone">이라고 써도 결과는 비슷하지만, 변환 의도가 "일단 골라낸 후 선택적으로 만든다"인지 "전부 선택적으로 바꾼 후 일부만 고른다" 인지는 분명히 다릅니다.
기존 타입에서 몇 개의 필드를 제외한 뒤, 특정 필드를 새로 덧붙이고 싶은 경우에는 Omit과 교차 타입(&)을 함께 사용합니다.
// 기본 User에서 password는 빼고, role 필드는 추가
type AdminUser = Omit<User, "password"> & {
role: "admin" | "super";
};
다만 조합이 깊어질수록 가독성이 떨어지는 점은 주의해야 합니다. 중첩이 서너 단계를 넘어가기 시작하면 타입 이름만 봐서는 의도를 파악하기 어려워집니다. 타입을 읽는 사람이 머릿속에서 여러 변환을 차례대로 적용해 결과를 상상해야 하기 때문입니다. 이런 경우에는 중간 단계 타입에 이름을 붙여서 분리하는 편이 유지보수에 훨씬 유리합니다. 타입에 이름을 붙이는 행위 자체가 일종의 문서 역할을 하기 때문입니다.
// ❌ 한 줄에 전부 다 담기
type A = Partial<Omit<Pick<User, "email" | "phone">, never>>;
// ✅ 단계별로 이름 붙이기
type Contact = Pick<User, "email" | "phone">;
type ContactPatch = Partial<Contact>;
이번 글에서는 실무에서 가장 자주 쓰이는 다섯 가지 유틸리티 타입을 살펴봤습니다. Partial<T>로 모든 필드를 선택적으로 바꾸고, Required<T>로 다시 모든 필드를 필수로 되돌릴 수 있습니다. Pick<T, K>로 원본에서 필요한 필드만 골라낼 수 있고, Omit<T, K>로 특정 필드를 빼낸 나머지 타입을 만들 수 있습니다. 그리고 Record<K, V>는 정해진 키 집합에 대한 값 타입을 강제할 때 유용합니다. 여러 유틸리티 타입을 조합하면 복잡한 변환도 선언적으로 표현할 수 있지만, 중첩이 깊어질 때는 중간 단계에 이름을 붙여 분리하는 편이 더 읽기 좋습니다.
비슷한 타입을 복사해서 조금씩 수정해가며 쓰고 있다면, 대부분의 경우 유틸리티 타입 한두 개로 중복을 깔끔하게 줄일 수 있습니다. 특히 API 요청/응답 타입, 폼 입력 타입, 상태 매핑 타입처럼 기존 모델에서 파생되는 타입이라면 유틸리티 타입의 활용 효과가 큽니다. 원본 모델 하나만 잘 관리하면, 파생 타입들은 컴파일러가 자동으로 따라와 주기 때문입니다.
유틸리티 타입에 익숙해지는 가장 좋은 방법은, 새 타입을 만들기 전에 한번 멈춰서 "이 타입은 혹시 기존 타입에서 파생된 것은 아닐까?"라고 스스로에게 물어보는 습관을 들이는 것입니다. 이런 질문을 거치다 보면, 인터페이스를 새로 정의하는 대신 유틸리티 타입으로 표현할 수 있는 경우가 생각보다 많다는 것을 알게 됩니다.
<참고>
©️요즘IT의 모든 콘텐츠는 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다.