Skip to main content

같은 타입을 또 쓰고 있다면, 이제 조립할 시간입니다

앞 장에서 우리는 제네릭을 배웠습니다. 같은 로직을 여러 타입에 재사용하는 방법이었죠. 이제 한 걸음 더 나아가 볼 차례입니다.

이번 장의 주인공은 맵드 타입, 조건부 타입, 그리고 유틸리티 타입입니다. 이름만 들으면 벌써 어려워 보입니다. 마치 타입스크립트 비밀 연구소의 지하 3층에서만 쓰는 기술 같죠.

하지만 실제로는 아주 현실적인 도구입니다.

예를 들어 이런 상황을 떠올려 봅시다.

interface User {
  id: number;
  name: string;
  email: string;
  password: string;
  isAdmin: boolean;
}

이제 프로젝트가 커지기 시작합니다.

  • 회원 수정 화면에서는 일부 값만 바꾸고 싶습니다.

  • 사용자 목록에서는 id, name만 있으면 됩니다.

  • 외부에 응답할 때는 password를 빼야 합니다.

  • 권한 표시는 역할 이름을 키로 갖는 객체가 필요합니다.

초보 시절에는 이렇게 갑니다.

interface UserUpdate {
  id?: number;
  name?: string;
  email?: string;
  password?: string;
  isAdmin?: boolean;
}

interface UserPreview {
  id: number;
  name: string;
}

interface PublicUser {
  id: number;
  name: string;
  email: string;
  isAdmin: boolean;
}

처음엔 그럴듯합니다. 그런데 User에 필드가 하나 추가되는 순간, 여기저기 같이 고쳐야 합니다. 한 군데라도 놓치면 타입이 서로 어긋나기 시작합니다. 바로 이런 문제를 줄이기 위해 타입스크립트는 기존 타입을 바탕으로 새 타입을 자동으로 만들어 주는 도구를 제공합니다.12

핵심 메시지는 아주 단순합니다.

고급 타입은 어려운 장식이 아니라, 반복 작업을 대신해 주는 자동화 도구입니다.

유틸리티 타입은 이미 준비된 공구함입니다

타입스크립트는 자주 쓰는 타입 변형을 미리 만들어 두었습니다. 이런 내장 도구를 유틸리티 타입이라고 부릅니다.1

먼저 실무에서 정말 자주 만나는 몇 가지부터 보겠습니다.

Partial<T>: 전부 선택 입력으로 바꾸기

Partial<T>는 어떤 타입의 모든 속성을 선택 사항으로 바꿉니다.

interface User {
  id: number;
  name: string;
  email: string;
}

type UserPatch = Partial<User>;

이제 UserPatch는 이렇게 됩니다.

// 대략 이런 느낌
// {
//   id?: number;
//   name?: string;
//   email?: string;
// }

그래서 수정 API나 PATCH 요청에 잘 어울립니다.

function updateUser(id: number, patch: Partial<User>) {
  // 바뀐 값만 받는다
}

updateUser(1, { name: "김타입" });

전체 User를 다시 적지 않아도 되니 편하고, 원본 타입이 바뀌어도 같이 따라옵니다.

Pick<T, K>: 필요한 것만 집기

Pick은 이름 그대로 필요한 속성만 골라 집습니다.

type UserPreview = Pick<User, "id" | "name">;

이건 목록 화면, 카드 UI, 요약 정보처럼 "다 필요 없고 일부만 필요해요" 상황에서 아주 좋습니다.

const preview: UserPreview = {
  id: 1,
  name: "김타입"
};

Omit<T, K>: 특정 것만 빼기

Omit은 반대로 몇 개만 제거합니다.

interface User {
  id: number;
  name: string;
  email: string;
  password: string;
}

type PublicUser = Omit<User, "password">;

이 타입은 "사용자 정보는 보여 주되 비밀번호는 빼자" 같은 상황에 잘 맞습니다.

const publicUser: PublicUser = {
  id: 1,
  name: "김타입",
  email: "[email protected]"
};

Pick과 Omit은 서로 반대 방향의 공구라고 생각하면 쉽습니다. 필요한 것만 집거나, 필요 없는 것만 빼거나입니다.

Record<K, V>: 키-값 표 만들기

Record는 "이 키들을 반드시 가지고, 값은 모두 같은 타입이어야 한다"는 객체를 만들 때 유용합니다.1

type Role = "admin" | "editor" | "viewer";

type RoleLabels = Record<Role, string>;

const roleLabels: RoleLabels = {
  admin: "관리자",
  editor: "편집자",
  viewer: "읽기 전용 사용자"
};

이 패턴은 번역 테이블, 권한 맵, 상태별 메시지 표를 만들 때 자주 등장합니다.

자주 함께 따라오는 친구들

앞의 네 가지가 가장 많이 쓰이지만, 몇 가지를 더 알아 두면 손이 빨라집니다.

Required<T>: 선택 속성을 다시 필수로

interface DraftUser {
  name?: string;
  email?: string;
}

type FinalUser = Required<DraftUser>;

임시 저장 단계에서는 선택 입력이었지만, 실제 제출 직전에는 모두 필요할 때 쓸 수 있습니다.

Readonly<T>: 읽기 전용으로 잠그기

type LockedUser = Readonly<User>;

이제 바깥쪽 속성 재할당이 막힙니다.

const user: Readonly<User> = {
  id: 1,
  name: "고정된 이름",
  email: "[email protected]"
};

// user.name = "변경"; // 오류

다만 여기서 중요한 점이 있습니다. Partial과 Readonly는 얕게(shallow) 동작합니다. 즉, 바깥쪽 속성만 바꾸고, 그 안쪽에 들어 있는 중첩 객체까지 전부 깊게 바꾸는 것은 아닙니다.3

interface Settings {
  user: {
    name: string;
  };
}

const original: Settings = {
  user: { name: "kim" }
};

const readonlySettings: Readonly<Settings> = original;

// readonlySettings.user = { name: "lee" }; // 오류
readonlySettings.user.name = "lee"; // 가능

이 부분은 초보자들이 자주 놀라는 지점입니다. "분명 읽기 전용이라며?" 하고요. 네, 바깥 속성 user 자체는 바꿀 수 없지만, 그 안의 중첩 객체 내용은 별개의 문제입니다.

그런데 이 유틸리티들은 어떻게 만들어졌을까요?

여기서 진짜 공방의 문이 열립니다.

Partial, Readonly, Record 같은 것들은 하늘에서 뚝 떨어진 마법이 아닙니다. 많은 경우 맵드 타입과 조건부 타입이라는 재료로 만들어집니다.45

즉, 유틸리티 타입은 완제품이고, 맵드 타입과 조건부 타입은 그 완제품을 만드는 기계라고 생각하면 됩니다.

맵드 타입: 타입용 for...in 반복문

맵드 타입은 기존 타입의 모든 속성을 한 바퀴 돌면서 새 타입을 만드는 방식입니다.4

가장 기본 모양은 이렇습니다.

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

처음 보면 눈이 한 번쯤 미끄러집니다. 하지만 조각내서 보면 어렵지 않습니다.

keyof T는 T의 속성 이름들을 꺼냅니다. K in keyof T는 그 이름들을 하나씩 순회합니다. T[K]는 해당 속성의 값 타입을 뜻합니다.

쉽게 말해 이렇게 읽으면 됩니다.

"T의 모든 키를 돌면서, 각 키의 타입을 그대로 가져와 새 객체 타입을 만든다."

직접 Partial 비슷한 타입 만들기

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

이건 거의 Partial<T>와 같은 아이디어입니다.

interface Product {
  id: number;
  name: string;
  price: number;
}

type ProductPatch = MyPartial<Product>;

결과는 이런 느낌입니다.

// {
//   id?: number;
//   name?: string;
//   price?: number;
// }

직접 Readonly 비슷한 타입 만들기

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

이제 모든 속성이 읽기 전용이 됩니다.

속성 타입 자체를 한꺼번에 바꾸기

맵드 타입은 선택 여부나 읽기 전용 여부만 바꾸는 기술이 아닙니다. 속성의 값 타입도 통째로 변형할 수 있습니다.

type Nullable<T> = {
  [K in keyof T]: T[K] | null;
};
interface Product {
  name: string;
  price: number;
  inStock: boolean;
}

type NullableProduct = Nullable<Product>;

이제 모든 속성은 null도 허용합니다.

맵드 타입의 좋은 점은 아주 분명합니다. 속성이 3개여도, 30개여도, 규칙은 한 번만 적으면 됩니다.

맵드 타입의 옵션 조절: ?, readonly, 그리고 빼기 기호

맵드 타입에서는 속성의 상태를 추가하거나 제거할 수 있습니다.4

예를 들어 선택 속성을 필수로 되돌리고 싶다면 이렇게 씁니다.

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

-?는 "선택 속성 표시를 제거하라"는 뜻입니다.

interface MaybeUser {
  id: number;
  name?: string;
}

type RealUser = Concrete<MaybeUser>;

이제 name도 필수입니다.

읽기 전용을 풀고 싶다면 이렇게 할 수 있습니다.

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

이 기능은 라이브러리 타입을 살짝 바꾸거나, 수정 가능 버전과 읽기 전용 버전을 오갈 때 유용합니다.

키 이름까지 바꿀 수 있습니다

맵드 타입은 값 타입만 바꾸는 데서 끝나지 않습니다. 키 이름 자체도 다시 만들 수 있습니다.4

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
interface User {
  name: string;
  age: number;
}

type UserGetters = Getters<User>;

그러면 결과는 이런 느낌이 됩니다.

// {
//   getName: () => string;
//   getAge: () => number;
// }

지금 당장 매일 쓰는 기능은 아닐 수 있습니다. 하지만 "타입 이름도 조립할 수 있다"는 감각을 얻는 데 아주 좋습니다. 타입스크립트의 타입 시스템은 생각보다 훨씬 공장형입니다.

조건부 타입: 타입 세계의 if

이제 두 번째 핵심 도구인 조건부 타입으로 가 보겠습니다.

조건부 타입은 타입 수준에서 조건문을 쓰는 방법입니다.5

기본 문법은 아주 단순합니다.

type SomeType<T> = T extends U ? X : Y;

읽는 법은 이렇습니다.

"T가 U에 들어맞으면 X, 아니면 Y."

가장 간단한 예

type IsString<T> = T extends string ? true : false;

type A = IsString<string>; // true
type B = IsString<number>; // false

물론 이 정도만 보면 "그래서 어디에 쓰죠?" 싶습니다. 진짜 재미는 제네릭과 함께 쓸 때 시작됩니다.

속성이 있으면 꺼내고, 없으면 never

type MessageOf<T> = T extends { message: unknown } ? T["message"] : never;
interface Email {
  message: string;
}

interface Dog {
  bark(): void;
}

type EmailMessage = MessageOf<Email>; // string
type DogMessage = MessageOf<Dog>; // never

이건 아주 실용적입니다. 어떤 타입에 특정 속성이 있는 경우에만 안전하게 값을 꺼내는 방식이기 때문입니다.

infer: 타입 안에서 꺼내오기

조건부 타입에서 특히 자주 만나는 키워드가 infer입니다. 이건 "여기서 타입 하나를 추론해서 꺼내 오자"는 뜻입니다.5

예를 들어 배열의 요소 타입을 꺼내고 싶다면 이렇게 씁니다.

type ElementType<T> = T extends Array<infer Item> ? Item : T;
type A = ElementType<string[]>; // string
type B = ElementType<number[]>; // number
type C = ElementType<boolean>; // boolean

함수의 반환 타입도 같은 방식으로 꺼낼 수 있습니다.

type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

infer는 처음엔 조금 낯설지만, 느낌은 단순합니다.

"타입 안에 들어 있는 부품을 뽑아내서 이름 붙이기."

우리가 늘 쓰던 유틸리티 타입도 사실 조건부 타입입니다

이제 익숙한 이름들이 다시 등장합니다.

Exclude<T, U>: 합집합에서 빼기

type Role = "admin" | "editor" | "viewer";
type NonAdminRole = Exclude<Role, "admin">;

결과는 이렇게 됩니다.

// "editor" | "viewer"

유니언 타입에서 특정 멤버를 제거할 때 유용합니다.15

Extract<T, U>: 합집합에서 골라내기

type AdminLike = Extract<Role, "admin" | "owner">;

결과는 "admin"입니다. 실제로 겹치는 것만 남기기 때문입니다.

NonNullable<T>: null, undefined 없애기

type UserId = string | null | undefined;
type SafeUserId = NonNullable<UserId>;

결과는 string입니다.

API 응답에서는 값이 비어 있을 수 있지만, 어떤 함수 안에서는 "이 시점부터는 비어 있지 않다"는 타입을 만들고 싶을 때 도움이 됩니다.

Parameters<T>와 ReturnType<T>: 함수 타입 재사용하기

function createUser(name: string, age: number) {
  return {
    id: 1,
    name,
    age
  };
}

type CreateUserParams = Parameters<typeof createUser>;
type CreateUserResult = ReturnType<typeof createUser>;

이제 CreateUserParams는 [name: string, age: number] 형태가 되고, CreateUserResult는 함수가 반환하는 객체 타입이 됩니다.15

이건 정말 실용적입니다. 함수 시그니처가 바뀌어도 관련 타입이 자동으로 따라가기 때문입니다.

조건부 타입의 아주 중요한 성질: 유니언에 퍼져서 적용됩니다

조건부 타입은 유니언 타입을 만나면 각 멤버에 따로 적용되는 성질이 있습니다. 이를 분배 법칙처럼 이해하면 편합니다.5

type ToArray<T> = T extends any ? T[] : never;

type Result = ToArray<string | number>;

이 결과는 이렇게 됩니다.

// string[] | number[]

왜냐하면 타입스크립트가 내부적으로 이렇게 생각하기 때문입니다.

// ToArray<string> | ToArray<number>

이 성질 덕분에 Exclude, Extract, NonNullable 같은 유틸리티 타입이 아주 자연스럽게 동작합니다.

처음엔 "왜 저렇게 되지?" 싶지만, 익숙해지면 오히려 강력한 자동 분류기처럼 느껴집니다.

실무에서 가장 많이 쓰는 조합들

이제 공구 설명은 충분합니다. 실제로 어떤 장면에서 쓰는지 보겠습니다.

수정 API용 타입 만들기

사용자 정보를 수정할 때는 보통 id나 생성일 같은 값은 수정하지 않습니다. 그리고 모든 값을 한 번에 다 보낼 필요도 없습니다.

interface User {
  id: number;
  name: string;
  email: string;
  password: string;
  createdAt: string;
}

type UpdateUserInput = Partial<Omit<User, "id" | "createdAt">>;

이 타입은 아주 많은 것을 자동으로 해 줍니다.

  • id, createdAt는 제거하고

  • 나머지는 모두 선택 입력으로 바꾸고

  • 원본 User와 계속 연결된 상태를 유지합니다

이런 타입을 손으로 만들었다면 금방 지쳤을 겁니다.

공개용 응답 타입 만들기

type PublicUser = Omit<User, "password">;

서버 응답, 프론트 전달 데이터, 로그 출력용 타입에서 자주 보게 됩니다.

다만 한 가지를 꼭 기억하세요.

타입은 런타임 데이터를 자동으로 지워 주지 않습니다.

예를 들어 아래 코드는 타입만 바꾼 것이지, 실제 객체에서 password를 삭제한 것은 아닙니다.

const rawUser = {
  id: 1,
  name: "kim",
  email: "[email protected]",
  password: "secret"
};

const typedUser: PublicUser = rawUser;

TypeScript는 typedUser를 PublicUser처럼 다루게 해 주지만, 런타임 객체 안의 password가 자동으로 사라지지는 않습니다. 실제로 값을 제거하려면 별도의 코드가 필요합니다.

const { password, ...safeUser } = rawUser;

즉, 타입은 설계도이지 자동 파쇄기는 아닙니다.

권한 맵 만들기

type Permission = "read" | "write" | "delete";
type PermissionMap = Record<Permission, boolean>;

const permissions: PermissionMap = {
  read: true,
  write: false,
  delete: false
};

키를 빼먹거나 엉뚱한 키를 넣으면 바로 오류가 납니다. 그래서 설정 객체에 특히 좋습니다.

함수 타입을 복붙 없이 재사용하기

function updateBook(id: number, title: string, price: number) {
  return { id, title, price };
}

type UpdateBookArgs = Parameters<typeof updateBook>;

type UpdateBookResult = ReturnType<typeof updateBook>;

function logUpdate(...args: UpdateBookArgs) {
  console.log(args);
}

함수 하나를 기준으로 다른 함수, 래퍼 함수, 테스트 헬퍼의 타입을 맞추고 싶을 때 정말 편합니다.

맵드 타입과 조건부 타입은 어떻게 연결될까요?

이 둘은 따로 노는 기술이 아닙니다. 함께 쓰면 힘이 훨씬 셉니다.

예를 들어 "문자열 속성만 남긴 타입" 같은 것도 만들 수 있습니다.

type OnlyStringProps<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};
interface Product {
  id: number;
  name: string;
  brand: string;
  price: number;
}

type ProductTextInfo = OnlyStringProps<Product>;

결과는 대략 이렇게 됩니다.

// {
//   name: string;
//   brand: string;
// }

여기서는 맵드 타입이 모든 키를 돌고, 조건부 타입이 "이 키를 남길지 말지"를 판단합니다. 공방의 컨베이어벨트와 검사기가 함께 일하는 셈입니다.

초보자가 헷갈리기 쉬운 포인트

이 장의 내용은 분명 유용하지만, 처음엔 몇 가지 함정이 있습니다.

  • Partial<T>는 속성을 선택으로 만들지, 값에 자동으로 null을 넣어 주는 것은 아닙니다.

  • Omit<T, K>는 타입에서만 제거할 뿐, 실제 객체에서 값을 삭제하지는 않습니다.

  • Readonly<T>는 기본적으로 얕게 동작합니다.

  • 너무 복잡한 타입을 한 줄에 몰아 쓰면 멋있어 보이기보다 미래의 나를 괴롭힐 수 있습니다.

특히 마지막이 중요합니다. 타입도 코드입니다. 읽히지 않으면 좋은 설계가 아닙니다.

언제 직접 만들고, 언제 내장 유틸리티를 쓸까요?

원칙은 간단합니다.

내장 유틸리티 타입으로 해결되면 그걸 쓰는 편이 가장 좋습니다. Partial, Pick, Omit, Record, Exclude, Extract, ReturnType은 이미 많은 개발자가 알고 있고, 읽기도 쉽습니다.1

직접 맵드 타입이나 조건부 타입을 만드는 건 다음과 같은 경우에 빛납니다.

  • 우리 프로젝트에서 반복되는 특별한 규칙이 있을 때

  • 내장 유틸리티를 조합해도 표현이 어색할 때

  • 타입 자동화를 더 세밀하게 하고 싶을 때

예를 들어 Nullable<T>나 OnlyStringProps<T> 같은 것은 프로젝트 성격에 따라 직접 만드는 편이 더 자연스러울 수 있습니다.

이 장의 핵심 감각

이번 장에서 가장 중요한 건 문법을 전부 외우는 것이 아닙니다.

중요한 건 이런 감각입니다.

"기존 타입을 복사해서 새로 쓰는 대신, 규칙을 적어서 자동으로 만들 수 없을까?"

이 질문을 떠올릴 수 있으면 이미 절반은 온 것입니다.

Partial은 "전부 선택으로 바꾸자"라는 규칙이고, Pick은 "이 키만 남기자"라는 규칙이며, Omit은 "이 키는 빼자"라는 규칙입니다.

맵드 타입은 "모든 속성을 돌면서 바꾸자"는 엔진이고, 조건부 타입은 "조건에 따라 다른 타입을 고르자"는 판단 장치입니다.

즉, 타입스크립트의 고급 타입은 멋 부리기가 아니라 반복되는 수작업을 규칙으로 바꾸는 기술입니다.

정리

이 장에서 배운 내용을 한 번에 묶어 보면 이렇습니다.

  • 유틸리티 타입은 자주 쓰는 타입 변형을 미리 만들어 둔 내장 도구입니다.

  • Partial, Pick, Omit, Record는 실무에서 특히 자주 씁니다.

  • 맵드 타입은 기존 타입의 모든 속성을 순회하며 새 타입을 만듭니다.

  • 조건부 타입은 타입 수준의 if 문처럼 동작합니다.

  • Exclude, Extract, NonNullable, Parameters, ReturnType 같은 타입은 조건부 타입의 대표적인 활용입니다.

  • 복잡한 타입의 목표는 뽐내기가 아니라, 중복을 줄이고 변경에 강한 코드를 만드는 것입니다.

한 문장으로 요약하면 이렇습니다.

맵드 타입과 조건부 타입은 타입을 더 어렵게 만드는 기술이 아니라, 타입을 덜 복붙하게 만드는 기술입니다.

다음 장에서는 이 타입 조립 기술을 현실 세계와 연결해 볼 것입니다. 비동기 코드, 모듈, 외부 라이브러리, API 데이터처럼 실제 프로젝트에서 마주치는 문제들 속에서 타입스크립트가 어떻게 빛나는지 살펴보겠습니다.

참고

1TypeScript: Documentation - Utility Types

2Understanding TypeScript Utility Types: Pick, Omit, and Beyond

3TypeScript: Documentation - Utility Types

4TypeScript: Documentation - Mapped Types

5TypeScript: Documentation - Conditional Types

책 목록

한 번에 이해되는 TypeScript: 초보자를 위한 가장 쉬운 타입스크립트 입문

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기