TypeScript 유틸리티 타입 | Partial, Pick, Omit, Record

이 글의 핵심

같은 User 타입을 생성용, 수정용, 응답용으로 매번 따로 선언하면 필드가 바뀔 때마다 여러 곳을 고쳐야 하고 어느 한 곳은 꼭 빠뜨리게 됩니다. 유틸리티 타입으로 원본 하나에서 파생 타입을 만들면 이런 불일치를 막을 수 있습니다. 각 유틸리티의 구현 원리를 함께 보면서 keyof와 매핑 타입이 어떻게 조합되는지도 익힙니다.

들어가며

유틸리티 타입은 이미 정의한 타입에서 일부만 골라 내거나, 모두 선택적으로 바꾸는 등 변환을 한 번에 해 주는 도구입니다. 같은 명판을 여러 형태로 찍어 쓰는 느낌에 가깝습니다.

유틸리티 타입이 특별한 문법이라고 생각하기 쉽지만, 대부분은 TypeScript 표준 라이브러리(lib.es5.d.ts)에 평범한 타입 별칭으로 정의되어 있습니다. 에디터에서 Partial에 Ctrl+클릭을 하면 이 글의 “구현 원리”와 거의 같은 코드가 나옵니다. 재료는 세 가지입니다. keyof T는 T의 키들을 유니온으로 뽑고, [K in 유니온]: ... 형태의 매핑 타입은 그 키들을 하나씩 돌며 새 객체 타입을 만들고, T extends U ? X : Y 형태의 조건부 타입은 타입에 따라 분기합니다. 이 세 가지를 이해하면 표준 유틸리티를 외울 필요 없이 필요한 변환을 직접 만들 수 있습니다.

이 모든 것은 컴파일 타임에만 존재한다는 점도 기억해야 합니다. Omit<User, "password"> 타입으로 선언한 변수라도 실제 객체에서 password 필드가 빠지는 것은 아닙니다. 타입은 컴파일 후 완전히 지워지므로, 응답에서 비밀번호를 빼려면 런타임 코드로 필드를 제거해야 합니다. 이 차이를 놓치는 것이 유틸리티 타입을 처음 쓸 때 가장 위험한 오해입니다.

Partial: 모든 속성을 선택적으로

모든 프로퍼티를 선택적(optional)으로 만듭니다.

interface User {
    id: string;
    name: string;
    email: string;
    age: number;
}
type PartialUser = Partial<User>;
// {
//     id?: string;
//     name?: string;
//     email?: string;
//     age?: number;
// }

부분 업데이트 함수에 쓰기

function updateUser(id: string, updates: Partial<User>): User {
    const user = getUser(id);
    return { ...user, ...updates };
}
updateUser("U001", { name: "김철수" });
updateUser("U002", { email: "[email protected]", age: 30 });

Partial의 매핑 타입 구현

type MyPartial<T> = {
    [K in keyof T]?: T[K];
};

updateUser 예제는 Partial의 대표적인 용도지만 두 가지 허점이 있습니다. 첫째, Partial<User>에는 id도 포함되므로 updateUser("U001", { id: "U999" })처럼 식별자를 바꾸는 업데이트가 타입 검사를 통과합니다. 수정하면 안 되는 필드는 Partial<Omit<User, "id">>로 빼 두는 것이 안전합니다(아래 API 타입 관리 예제가 이 방식입니다). 둘째, 선택적 프로퍼티는 기본 설정에서 undefined를 명시적으로 넣는 것도 허용합니다. updateUser("U001", { name: undefined })는 타입 에러가 없지만, 스프레드로 합치면 name이 undefined로 덮여 “필수 필드가 비어 있는 User”가 반환됩니다. tsconfig의 exactOptionalPropertyTypes를 켜면 “없음”과 “undefined 값”을 구분해 이 경우를 에러로 잡아 줍니다.

구현 원리의 ? 수식어가 핵심이고, T[K]는 인덱스 접근 타입으로 원래 프로퍼티의 타입을 그대로 가져옵니다. Partial은 한 단계만 적용되어 중첩 객체 안의 필드는 여전히 필수라는 점도 알아 두세요. 설정 객체처럼 깊은 구조를 부분 업데이트해야 한다면 재귀적으로 적용하는 DeepPartial을 직접 정의해야 합니다.


Required: 선택적 속성을 필수로

모든 프로퍼티를 필수(required)로 만듭니다.

interface User {
    id: string;
    name: string;
    email?: string;
    age?: number;
}
type RequiredUser = Required<User>;
// {
//     id: string;
//     name: string;
//     email: string;
//     age: number;
// }

-? 수정자로 구현하기

type MyRequired<T> = {
    [K in keyof T]-?: T[K];
};

-?는 “선택적 수식어를 제거하라”는 매핑 수식어입니다. +?(추가, ?와 같음)와 짝을 이루며, readonly에도 -readonly로 같은 방식을 쓸 수 있습니다. -?는 ? 때문에 붙었던 undefined도 함께 제거하므로, email?: string은 email: string이 됩니다. 다만 원래 타입에 email: string | undefined처럼 명시적으로 undefined가 들어 있었다면 그것은 지워지지 않습니다. Required는 기본값을 채운 뒤의 “완성된 설정” 타입을 표현할 때 자주 씁니다. 예를 들어 사용자가 일부만 넘긴 Partial<Options>에 기본값을 합친 결과를 Required<Options>로 선언하면, 이후 코드에서 options.timeout ?? 3000 같은 방어 코드가 필요 없어집니다.


Readonly: 모든 속성을 읽기 전용으로

모든 프로퍼티를 읽기 전용으로 만듭니다.

interface User {
    id: string;
    name: string;
    email: string;
}
type ReadonlyUser = Readonly<User>;
const user: ReadonlyUser = {
    id: "U001",
    name: "홍길동",
    email: "[email protected]"
};
// user.name = "김철수";  // ❌ 에러

readonly 매핑으로 구현하기

type MyReadonly<T> = {
    readonly [K in keyof T]: T[K];
};

주석 처리된 줄의 주석을 풀면 “Cannot assign to ‘name’ because it is a read-only property”(TS2540) 에러가 납니다. 여기에도 두 가지 한계가 있습니다. Readonly는 얕게 적용되어 user.address.city = "부산"처럼 중첩 객체의 필드는 여전히 바꿀 수 있고, 배열 프로퍼티에 push하는 것도 막지 못합니다. 또 컴파일 타임 검사일 뿐이라 런타임에 객체가 실제로 얼어붙지는 않습니다. 런타임 불변성이 필요하면 Object.freeze를 써야 하며, 그 반환 타입이 Readonly<T>로 추론됩니다. 배열이라면 readonly string[]나 ReadonlyArray<string>을 쓰면 push, splice 같은 변경 메서드 자체가 타입에서 사라집니다. 리터럴 객체 전체를 깊게 읽기 전용으로 만들고 싶다면 as const가 가장 간단합니다.


Pick<T, K>: 필요한 속성만 고르기

특정 프로퍼티만 선택합니다.

interface User {
    id: string;
    name: string;
    email: string;
    age: number;
    address: string;
}
type UserPreview = Pick<User, "id" | "name">;
// {
//     id: string;
//     name: string;
// }
const preview: UserPreview = {
    id: "U001",
    name: "홍길동"
};

로그인·회원가입 폼 타입 만들기

type LoginForm = Pick<User, "email">;
type SignupForm = Pick<User, "name" | "email" | "age">;

keyof 제약으로 구현하기

type MyPick<T, K extends keyof T> = {
    [P in K]: T[P];
};

Pick의 K extends keyof T 제약 덕분에 Pick<User, "nmae">처럼 없는 키를 넣으면 “Type ‘“nmae”’ does not satisfy the constraint ‘keyof User’” 에러가 납니다. 이 제약이 있어서 Pick은 오타에 안전합니다. 또 Pick으로 만든 타입은 원본과 연결되어 있어서, 원본 User의 email 타입을 string에서 Email 브랜드 타입으로 바꾸면 LoginForm도 자동으로 따라 바뀝니다. 같은 필드를 가진 인터페이스를 손으로 복사해 두었다면 한쪽만 바뀌어 불일치가 생겼을 부분입니다. 반대로 폼 타입이 원본과 독립적으로 진화해야 한다면(예: 폼에서는 나이를 문자열로 입력받음) 파생 대신 별도 타입으로 두는 편이 맞습니다.


Omit<T, K>: 특정 속성 빼기

특정 프로퍼티를 제외합니다.

interface User {
    id: string;
    name: string;
    email: string;
    password: string;
}
type UserWithoutPassword = Omit<User, "password">;
// {
//     id: string;
//     name: string;
//     email: string;
// }
const user: UserWithoutPassword = {
    id: "U001",
    name: "홍길동",
    email: "[email protected]"
};

응답에서 password, 생성 요청에서 id 빼기

type UserResponse = Omit<User, "password">;
type CreateUserRequest = Omit<User, "id">;

Pick과 Exclude로 구현하기

type MyOmit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

Omit은 “T의 모든 키 중 K를 뺀 나머지를 Pick한다”로 구현됩니다. 여기서 주의할 점은 표준 Omit의 선언이 Omit<T, K extends keyof any>라서 존재하지 않는 키도 받는다는 것입니다(FAQ 참고). 이 글의 MyOmit은 K extends keyof T로 제약을 걸어 오타를 잡습니다. 팀 코드베이스에서는 이런 “엄격한 Omit”을 별도로 정의해 쓰는 경우가 많습니다.

또 하나의 함정은 유니온 타입에 Omit을 쓸 때입니다. type Shape = Circle | Square;에 Omit<Shape, "id">를 적용하면, keyof (Circle | Square)가 두 타입의 공통 키만 돌려주기 때문에 radius나 side 같은 개별 필드가 전부 사라진 타입이 나옵니다. 판별 유니온에서 필드 하나를 빼려면 각 멤버에 따로 적용되도록 type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;처럼 조건부 타입으로 감싸야 합니다. 조건부 타입이 유니온의 각 멤버에 분배되는 성질을 이용한 것으로, 다음 절의 Exclude가 동작하는 원리와 같습니다.


Record<K, T>: 키-값 맵 타입

키와 값의 타입을 지정하여 객체 타입을 만듭니다.

type Role = "admin" | "user" | "guest";
type Permissions = Record<Role, string[]>;
const permissions: Permissions = {
    admin: ["read", "write", "delete"],
    user: ["read", "write"],
    guest: ["read"]
};

Record 활용 예

type ErrorCode = "NOT_FOUND" | "UNAUTHORIZED" | "SERVER_ERROR";
type ErrorMessages = Record<ErrorCode, string>;
const errors: ErrorMessages = {
    NOT_FOUND: "리소스를 찾을 수 없습니다",
    UNAUTHORIZED: "인증이 필요합니다",
    SERVER_ERROR: "서버 에러가 발생했습니다"
};
type Language = "ko" | "en" | "ja";
type Translations = Record<Language, Record<string, string>>;
const translations: Translations = {
    ko: { greeting: "안녕하세요", goodbye: "안녕히 가세요" },
    en: { greeting: "Hello", goodbye: "Goodbye" },
    ja: { greeting: "こんにちは", goodbye: "さようなら" }
};

Record의 구현

type MyRecord<K extends keyof any, T> = {
    [P in K]: T;
};

Record의 진가는 키를 리터럴 유니온으로 줄 때 드러납니다. Record<Role, string[]>로 선언한 객체에서 guest를 빠뜨리면 “Property ‘guest’ is missing in type …” 에러가 나고, 나중에 Role에 "moderator"를 추가하면 권한 객체를 채우지 않은 모든 곳이 컴파일 에러로 드러납니다. 새 역할을 추가하고 권한 설정을 깜빡하는 실수를 타입이 막아 주는 셈입니다. ErrorMessages도 같은 방식으로, 에러 코드를 추가하면 메시지를 반드시 함께 작성하게 됩니다.

반대로 Record<string, string>처럼 키를 string으로 주면 이 보호가 사라집니다. translations.ko.greetng처럼 오타가 난 키로 접근해도 타입은 string이라 에러가 없고, 런타임에는 undefined가 나옵니다. 기본 설정에서 TypeScript는 인덱스 시그니처 접근 결과에 undefined 가능성을 넣지 않기 때문입니다. noUncheckedIndexedAccess 옵션을 켜면 결과가 string | undefined로 바뀌어 확인을 강제합니다. 번역 키가 고정되어 있다면 Record<Language, Record<"greeting" | "goodbye", string>>처럼 안쪽 키도 유니온으로 좁히는 편이 안전합니다.


Exclude<T, U>: 유니온에서 제거

Union 타입에서 특정 타입을 제외합니다.

type AllRoles = "admin" | "user" | "guest" | "moderator";
type NonAdminRoles = Exclude<AllRoles, "admin">;
// "user" | "guest" | "moderator"
let role: NonAdminRoles = "user";

Exclude의 정의는 type Exclude<T, U> = T extends U ? never : T; 한 줄입니다. 조건부 타입에 유니온이 들어오면 TypeScript는 각 멤버에 따로 조건을 적용하고 결과를 다시 합칩니다(분배 조건부 타입). 그래서 "admin"은 never가 되어 사라지고 나머지는 남습니다. never는 유니온에서 자동으로 지워지는 “빈” 타입이라 필터링에 쓸 수 있습니다. Omit과 이름이 비슷해 헷갈리는데, Exclude는 유니온의 멤버를 빼고 Omit은 객체의 프로퍼티를 뺀다고 구분하면 됩니다. Exclude<User, "password">라고 쓰면 에러 없이 User가 그대로 나오는 것이 흔한 실수입니다.


Extract<T, U>: 유니온에서 추출

Union 타입에서 특정 타입만 추출합니다.

type AllRoles = "admin" | "user" | "guest" | "moderator";
type AdminRoles = Extract<AllRoles, "admin" | "moderator">;
// "admin" | "moderator"
let role: AdminRoles = "admin";

NonNullable: null·undefined 제거

null과 undefined를 제거합니다.

type MaybeString = string | null | undefined;
type DefiniteString = NonNullable<MaybeString>;
// string
let value: DefiniteString = "hello";

Extract는 Exclude의 반대(T extends U ? T : never)이고, NonNullable은 최신 버전에서 T & {}로 정의되어 null과 undefined를 걸러 냅니다. NonNullable은 타입만 바꿀 뿐 값을 검사하지는 않으므로, 실제로 null일 수 있는 값을 NonNullable 타입 변수에 넣으려면 먼저 if (value != null) 같은 검사로 좁혀야 합니다. value as NonNullable<...>나 value!로 단언해 버리면 컴파일러는 믿어 주지만 런타임 에러는 그대로 남습니다.


ReturnType: 함수 반환 타입 얻기

함수의 반환 타입을 추출합니다.

function getUser() {
    return {
        id: "U001",
        name: "홍길동",
        email: "[email protected]"
    };
}
type User = ReturnType<typeof getUser>;

ReturnType에는 함수 타입을 넘겨야 하므로 typeof getUser가 필요합니다. ReturnType<getUser>라고 쓰면 “‘getUser’ refers to a value, but is being used as a type here” 에러가 납니다. 이 방식은 반환 타입을 따로 선언하지 않은 함수나 외부 라이브러리 함수의 결과 타입을 가져올 때 유용합니다. async 함수라면 결과가 Promise<...>로 나오므로, 안의 값 타입이 필요하면 Awaited<ReturnType<typeof fetchUser>>처럼 Awaited로 한 번 더 벗겨야 합니다.

다만 구현에서 타입을 끌어내는 방향이라는 점은 양날의 검입니다. 함수 본문을 고치다 필드 하나를 실수로 지우면 User 타입도 조용히 바뀌고, 에러는 그 타입을 쓰는 먼 곳에서 뜹니다. 여러 모듈이 공유하는 핵심 도메인 타입이라면 인터페이스를 먼저 선언하고 함수의 반환 타입으로 명시하는 편이 계약이 분명합니다.


Parameters: 함수 매개변수 튜플 얻기

함수의 매개변수 타입을 튜플로 추출합니다.

function createUser(name: string, age: number, email: string) {
    return { name, age, email };
}
type CreateUserParams = Parameters<typeof createUser>;
// [string, number, string]
const params: CreateUserParams = ["홍길동", 25, "[email protected]"];
createUser(...params);

예제: API 타입·폼 상태·스토어 상태 관리

예제 1: API 타입 관리

interface User {
    id: string;
    name: string;
    email: string;
    password: string;
    createdAt: Date;
    updatedAt: Date;
}
type CreateUserRequest = Omit<User, "id" | "createdAt" | "updatedAt">;
type UpdateUserRequest = Partial<Omit<User, "id" | "createdAt" | "updatedAt">>;
type UserResponse = Omit<User, "password">;
type UserListItem = Pick<User, "id" | "name" | "email">;

원본 User 하나에서 네 개의 파생 타입을 만든 이 구조가 유틸리티 타입을 쓰는 가장 큰 이유입니다. User에 phone 필드를 추가하면 생성 요청, 수정 요청, 응답 타입에 자동으로 반영되고, UserListItem처럼 필요한 필드를 Pick으로 명시한 타입만 그대로 남습니다. 새 필드가 목록 화면에 노출되지 않아야 한다면 Pick이, 기본적으로 모두 포함되어야 한다면 Omit이 맞는 선택입니다.

여기서 조심할 것은 민감한 필드를 Omit으로 빼는 응답 타입입니다. 나중에 User에 resetToken 같은 민감한 필드가 추가되면 Omit<User, "password">인 UserResponse에 자동으로 포함되어 버립니다. 저는 이런 이유로 외부에 나가는 응답 타입은 Omit 대신 Pick으로 허용 목록을 명시하는 쪽을 선호합니다. 앞서 말했듯 타입은 런타임에 필드를 걸러 주지 않으므로, 직렬화 단계에서도 허용된 필드만 골라 내보내는 코드가 함께 있어야 합니다. 또 createdAt: Date는 JSON으로 오가면 문자열이 되므로, API 경계에서는 Date 대신 string으로 선언하거나 변환 계층을 두는 편이 정확합니다.

예제 2: 폼 상태 관리

interface FormField<T> {
    value: T;
    error: string | null;
    touched: boolean;
}
type FormState<T> = {
    [K in keyof T]: FormField<T[K]>;
};
interface LoginData {
    email: string;
    password: string;
}
type LoginFormState = FormState<LoginData>;
const form: LoginFormState = {
    email: { value: "", error: null, touched: false },
    password: { value: "", error: null, touched: false }
};

FormState<T>는 표준 유틸리티가 아니라 직접 만든 매핑 타입이라는 점이 이 예제의 요점입니다. 원리는 Partial과 똑같이 keyof T를 돌면서, 각 필드의 타입 T[K]를 FormField<T[K]>로 감쌉니다. 그래서 LoginData에 rememberMe: boolean을 추가하면 폼 상태에 rememberMe: FormField<boolean>이 자동으로 생기고, 초기값 객체에 이 필드를 빠뜨리면 컴파일 에러가 납니다. React Hook Form이나 Formik 같은 폼 라이브러리의 타입도 내부적으로 이런 매핑 타입으로 만들어져 있습니다.

예제 3: 상태 관리

interface AppState {
    user: User | null;
    posts: Post[];
    loading: boolean;
    error: string | null;
}
type LoadingState = Pick<AppState, "loading">;
type ErrorState = Pick<AppState, "error">;
type DataState = Omit<AppState, "loading" | "error">;

(Post 타입은 다른 곳에 정의되어 있다고 가정합니다.) 이 방식은 상태의 일부만 받는 컴포넌트나 셀렉터의 타입을 만들 때 유용합니다. 다만 loading: boolean과 error: string | null, user: User | null을 따로 두는 구조 자체는 “로딩 중인데 에러도 있음” 같은 불가능한 조합을 허용한다는 한계가 있습니다. 상태 전이가 복잡하다면 { status: "loading" } | { status: "error"; error: string } | { status: "success"; user: User } 같은 판별 유니온으로 설계하면, 유틸리티 타입으로 쪼개는 것보다 잘못된 상태를 원천적으로 막을 수 있습니다.


유틸리티 타입 요약

  1. Partial: 모든 프로퍼티 선택적
  2. Required: 모든 프로퍼티 필수
  3. Readonly: 모든 프로퍼티 읽기 전용
  4. Pick: 특정 프로퍼티 선택
  5. Omit: 특정 프로퍼티 제외
  6. Record: 키-값 객체 타입
  7. Exclude/Extract: Union 타입 필터링
  8. ReturnType: 함수 반환 타입
  9. Parameters: 함수 매개변수 타입

유틸리티 타입 비교

타입용도예시
Partial선택적 변환업데이트
Required필수 변환완전한 데이터
Pick선택일부 필드
Omit제외비밀번호 제외
Record객체 생성권한, 에러 메시지

다음 단계


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. Omit에 존재하지 않는 키를 넣어도 에러가 나지 않는 이유는 무엇인가요?

A. 내장 Omit은 K를 keyof T가 아니라 모든 키 타입으로 받도록 정의되어 있어서, Omit<User, “pasword”>처럼 오타가 난 키를 넘겨도 에러 없이 원래 타입을 그대로 돌려줍니다. 그래서 password를 빼려던 응답 타입에 password가 그대로 남는 실수가 생길 수 있습니다. 이 글의 MyOmit처럼 K extends keyof T 제약을 건 타입을 쓰거나, 남길 필드가 적다면 Pick으로 명시하는 편이 안전합니다.