본문으로 건너뛰기

5.3 내장 유틸리티 타입

유틸리티 타입이란

TypeScript는 자주 쓰이는 타입 변환 패턴을 유틸리티 타입(Utility Types) 으로 미리 정의해 제공합니다. 이들은 모두 제네릭으로 구현되어 있으며, 기존 타입을 기반으로 새로운 타입을 파생시킵니다.

interface User {
id: number;
name: string;
email: string;
password: string;
role: "admin" | "user";
createdAt: Date;
}

// 유틸리티 타입으로 다양한 파생 타입 생성
type PartialUser = Partial<User>; // 모든 필드 선택적
type PublicUser = Omit<User, "password">; // password 제거
type UserFormData = Pick<User, "name" | "email" | "password">; // 일부만
type ReadonlyUser = Readonly<User>; // 모든 필드 읽기 전용

유틸리티 타입을 사용하면 타입을 중복 정의하지 않고, 한 곳에서 관리하면서 다양한 변형을 만들 수 있습니다.


Partial<T>

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

interface Post {
id: number;
title: string;
content: string;
published: boolean;
tags: string[];
}

// 모든 필드가 optional
type PartialPost = Partial<Post>;
// {
// id?: number;
// title?: string;
// content?: string;
// published?: boolean;
// tags?: string[];
// }

// 업데이트 함수에서 활용
function updatePost(id: number, updates: Partial<Post>): Post {
// DB에서 기존 포스트 가져온 후 updates 병합
const existing = getPostFromDb(id);
return { ...existing, ...updates };
}

// 일부 필드만 업데이트 가능
updatePost(1, { title: "새 제목" }); // OK
updatePost(1, { title: "새 제목", published: true }); // OK
updatePost(1, { nonExistent: "값" }); // Error

Partial 직접 구현

// TypeScript 내장 구현 방식
type MyPartial<T> = {
[K in keyof T]?: T[K];
};

// 테스트
type MyPartialPost = MyPartial<Post>;
// Partial<Post>와 동일한 결과

Required<T>

모든 프로퍼티를 필수로 만듭니다. -? 수정자를 사용해 선택적 표시를 제거합니다.

interface Config {
host?: string;
port?: number;
debug?: boolean;
timeout?: number;
}

// 모든 필드가 필수
type StrictConfig = Required<Config>;
// {
// host: string;
// port: number;
// debug: boolean;
// timeout: number;
// }

// 기본값 병합 후 완성된 설정 반환
const DEFAULT_CONFIG: Required<Config> = {
host: "localhost",
port: 3000,
debug: false,
timeout: 5000,
};

function resolveConfig(userConfig: Config): Required<Config> {
return { ...DEFAULT_CONFIG, ...userConfig };
}

const config = resolveConfig({ port: 8080 });
config.host; // string (undefined 아님)
config.port; // 8080
config.debug; // false
config.timeout; // 5000

Required 직접 구현

type MyRequired<T> = {
[K in keyof T]-?: T[K]; // -? 는 선택적 표시 제거
};

Readonly<T>

모든 프로퍼티를 readonly로 만들어 수정을 방지합니다.

interface Point {
x: number;
y: number;
}

const origin: Readonly<Point> = { x: 0, y: 0 };
origin.x = 1; // Error: Cannot assign to 'x' because it is a read-only property

// 불변 설정 객체
const APP_CONFIG = Object.freeze({
apiUrl: "https://api.example.com",
version: "1.0.0",
maxRetries: 3,
}) as Readonly<{
apiUrl: string;
version: string;
maxRetries: number;
}>;

// 함수 매개변수를 readonly로 선언해 의도치 않은 변경 방지
function processUsers(users: Readonly<User[]>): string[] {
// users.push(...) // Error: push는 Readonly 배열에서 불가
return users.map((u) => u.name);
}

Readonly 직접 구현

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

Pick<T, K>

타입 T에서 K로 지정한 키들만 포함하는 새 타입을 만듭니다.

interface Article {
id: number;
title: string;
content: string;
summary: string;
author: string;
publishedAt: Date;
viewCount: number;
tags: string[];
}

// 목록 표시용 — 무거운 content 제외
type ArticleListItem = Pick<Article, "id" | "title" | "summary" | "publishedAt" | "author">;
// {
// id: number;
// title: string;
// summary: string;
// publishedAt: Date;
// author: string;
// }

// SEO 메타데이터용
type ArticleMeta = Pick<Article, "title" | "summary" | "tags">;

// 사용
function renderArticleCard(article: ArticleListItem): string {
return `<h2>${article.title}</h2><p>${article.summary}</p>`;
}

Pick 직접 구현

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

// 테스트
type MyArticleListItem = MyPick<Article, "id" | "title" | "summary">;

Omit<T, K>

타입 T에서 K로 지정한 키들을 제외한 새 타입을 만듭니다.

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

// 민감 정보 제외
type PublicUser = Omit<User, "password">;
// {
// id: number;
// name: string;
// email: string;
// createdAt: Date;
// }

// 생성 시에는 id, createdAt을 서버가 자동 생성
type CreateUserDto = Omit<User, "id" | "createdAt">;
// {
// name: string;
// email: string;
// password: string;
// }

function createUser(dto: CreateUserDto): User {
return {
...dto,
id: Math.random(),
createdAt: new Date(),
};
}

Omit 직접 구현

// Omit은 Pick + Exclude로 구현
type MyOmit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

// 또는 Mapped Type으로 직접
type MyOmit2<T, K extends keyof T> = {
[P in keyof T as P extends K ? never : P]: T[P];
};

Exclude<T, U>

유니온 타입 T에서 U에 할당 가능한 타입을 제거합니다.

type Status = "pending" | "active" | "inactive" | "deleted";

// "deleted"를 제외한 활성 상태들
type ActiveStatus = Exclude<Status, "deleted">;
// "pending" | "active" | "inactive"

// 여러 타입 제거
type WorkingStatus = Exclude<Status, "inactive" | "deleted">;
// "pending" | "active"

// null/undefined 제거 (NonNullable과 유사)
type NonNull = Exclude<string | null | undefined, null | undefined>;
// string

// 함수 타입 제거
type PrimitiveOnly = Exclude<string | number | (() => void), Function>;
// string | number

Exclude 직접 구현

// 분산 조건부 타입으로 구현
type MyExclude<T, U> = T extends U ? never : T;

// 작동 원리 (분산)
// MyExclude<"a" | "b" | "c", "b">
// = MyExclude<"a", "b"> | MyExclude<"b", "b"> | MyExclude<"c", "b">
// = "a" | never | "c"
// = "a" | "c"

Extract<T, U>

유니온 타입 T에서 U에 할당 가능한 타입만 추출합니다. Exclude의 반대입니다.

type AllTypes = string | number | boolean | null | undefined | object;

// 원시 타입만 추출
type Primitives = Extract<AllTypes, string | number | boolean>;
// string | number | boolean

// 공통 멤버 추출
type A = "admin" | "user" | "moderator";
type B = "user" | "moderator" | "guest";
type Common = Extract<A, B>;
// "user" | "moderator"

// 특정 구조를 만족하는 타입 추출
type Actions =
| { type: "INCREMENT"; payload: number }
| { type: "DECREMENT"; payload: number }
| { type: "RESET" }
| { type: "SET_USER"; payload: string };

type PayloadActions = Extract<Actions, { payload: unknown }>;
// { type: "INCREMENT"; payload: number }
// | { type: "DECREMENT"; payload: number }
// | { type: "SET_USER"; payload: string }

Extract 직접 구현

type MyExtract<T, U> = T extends U ? T : never;

NonNullable<T>

타입 T에서 nullundefined를 제거합니다.

type MaybeString = string | null | undefined;
type DefiniteString = NonNullable<MaybeString>; // string

type MaybeUser = User | null | undefined;
type DefiniteUser = NonNullable<MaybeUser>; // User

// API 응답 처리에서 유용
function assertDefined<T>(
value: T | null | undefined,
message: string
): NonNullable<T> {
if (value === null || value === undefined) {
throw new Error(message);
}
return value as NonNullable<T>;
}

const maybeUser: User | null = getUserOrNull();
const user = assertDefined(maybeUser, "사용자를 찾을 수 없습니다.");
// user는 User 타입 (null 아님)

NonNullable 직접 구현

type MyNonNullable<T> = T extends null | undefined ? never : T;
// TypeScript 4.8+ 에서는 T & {} 형태로도 표현 가능
type MyNonNullable2<T> = T & {};

Record<K, V>

키 타입 K와 값 타입 V로 구성된 객체 타입을 만듭니다.

// 기본 사용
type UserMap = Record<number, User>;
type RolePermissions = Record<"admin" | "user" | "moderator", string[]>;
type Cache = Record<string, unknown>;

// 열거형 키와 결합
enum HttpMethod {
GET = "GET",
POST = "POST",
PUT = "PUT",
DELETE = "DELETE",
PATCH = "PATCH",
}

type MethodHandlers = Record<HttpMethod, (req: Request) => Response>;

// 상태별 색상 맵
type StatusColor = Record<
"success" | "error" | "warning" | "info",
{ bg: string; text: string; border: string }
>;

const statusColors: StatusColor = {
success: { bg: "#d4edda", text: "#155724", border: "#c3e6cb" },
error: { bg: "#f8d7da", text: "#721c24", border: "#f5c6cb" },
warning: { bg: "#fff3cd", text: "#856404", border: "#ffeeba" },
info: { bg: "#d1ecf1", text: "#0c5460", border: "#bee5eb" },
};

Record 직접 구현

type MyRecord<K extends string | number | symbol, V> = {
[P in K]: V;
};

ReturnType<T> 와 Parameters<T>

함수 타입에서 반환 타입과 매개변수 타입을 추출합니다.

// ReturnType
function fetchUser(id: number): Promise<User> {
return fetch(`/api/users/${id}`).then((r) => r.json());
}

type FetchUserReturn = ReturnType<typeof fetchUser>;
// Promise<User>

function createRange(start: number, end: number): number[] {
return Array.from({ length: end - start }, (_, i) => start + i);
}

type RangeResult = ReturnType<typeof createRange>; // number[]

// Parameters
type FetchUserParams = Parameters<typeof fetchUser>;
// [id: number]

type CreateRangeParams = Parameters<typeof createRange>;
// [start: number, end: number]

// 활용: 함수를 래핑할 때 타입 유지
function memoize<T extends (...args: any[]) => any>(
fn: T
): (...args: Parameters<T>) => ReturnType<T> {
const cache = new Map<string, ReturnType<T>>();
return (...args: Parameters<T>): ReturnType<T> => {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key)!;
}
const result = fn(...args);
cache.set(key, result);
return result;
};
}

const memoizedRange = memoize(createRange);
memoizedRange(1, 10); // number[] — 타입 유지

기타 함수 관련 유틸리티

// ConstructorParameters<T>: 생성자 매개변수 타입 추출
class Vector {
constructor(public x: number, public y: number, public z: number) {}
}

type VectorArgs = ConstructorParameters<typeof Vector>;
// [x: number, y: number, z: number]

// InstanceType<T>: 클래스의 인스턴스 타입 추출
type VectorInstance = InstanceType<typeof Vector>;
// Vector

// Awaited<T>: Promise를 벗겨낸 타입 (TypeScript 4.5+)
type UserData = Awaited<Promise<User>>; // User
type NestedData = Awaited<Promise<Promise<string>>>; // string

유틸리티 타입 직접 구현 (학습용)

// 지금까지 본 모든 유틸리티 타입 직접 구현
namespace MyUtility {
export type Partial<T> = { [K in keyof T]?: T[K] };
export type Required<T> = { [K in keyof T]-?: T[K] };
export type Readonly<T> = { readonly [K in keyof T]: T[K] };
export type Pick<T, K extends keyof T> = { [P in K]: T[P] };
export type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
export type Exclude<T, U> = T extends U ? never : T;
export type Extract<T, U> = T extends U ? T : never;
export type NonNullable<T> = T extends null | undefined ? never : T;
export type Record<K extends keyof any, V> = { [P in K]: V };
export type ReturnType<T extends (...args: any) => any> =
T extends (...args: any) => infer R ? R : never;
export type Parameters<T extends (...args: any) => any> =
T extends (...args: infer P) => any ? P : never;
}

실전 예제

DTO 변환 패턴

// 도메인 모델
interface UserEntity {
id: number;
name: string;
email: string;
passwordHash: string;
salt: string;
createdAt: Date;
updatedAt: Date;
deletedAt: Date | null;
}

// 응답 DTO: 민감 정보 제거
type UserResponseDto = Omit<UserEntity, "passwordHash" | "salt" | "deletedAt">;

// 생성 DTO: 서버 생성 필드 제거, password 추가
type CreateUserDto = Omit<UserEntity, "id" | "passwordHash" | "salt" | "createdAt" | "updatedAt" | "deletedAt"> & {
password: string;
passwordConfirm: string;
};

// 업데이트 DTO: id 외 모두 선택적
type UpdateUserDto = Partial<Omit<CreateUserDto, "password" | "passwordConfirm">> & {
id: number;
};

// 사용
const createDto: CreateUserDto = {
name: "Alice",
email: "alice@example.com",
password: "secure123",
passwordConfirm: "secure123",
};

const updateDto: UpdateUserDto = {
id: 1,
name: "Alice Updated", // 이름만 업데이트
};

설정 객체 병합 패턴

// 기본 설정과 사용자 설정을 안전하게 병합
interface ServerConfig {
host: string;
port: number;
ssl: boolean;
timeout: number;
maxConnections: number;
cors: {
origin: string[];
credentials: boolean;
};
}

type UserServerConfig = Partial<ServerConfig>;

const defaults: Required<ServerConfig> = {
host: "localhost",
port: 3000,
ssl: false,
timeout: 30000,
maxConnections: 100,
cors: {
origin: ["*"],
credentials: false,
},
};

function createServerConfig(userConfig: UserServerConfig): Required<ServerConfig> {
return {
...defaults,
...userConfig,
cors: {
...defaults.cors,
...(userConfig.cors ?? {}),
},
};
}

const config = createServerConfig({
port: 8080,
ssl: true,
});
// host: "localhost", port: 8080, ssl: true, ...나머지는 기본값

업데이트 페이로드 타입

// 어떤 엔티티든 안전하게 업데이트
type UpdatePayload<T extends { id: number }> = Pick<T, "id"> & Partial<Omit<T, "id">>;

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

type UpdateProductPayload = UpdatePayload<Product>;
// { id: number; name?: string; price?: number; stock?: number; category?: string; }

function updateProduct(payload: UpdateProductPayload): Promise<Product> {
return fetch(`/api/products/${payload.id}`, {
method: "PATCH",
body: JSON.stringify(payload),
}).then((r) => r.json());
}

updateProduct({ id: 1, price: 29.99 }); // 가격만 업데이트
updateProduct({ id: 2, stock: 0, name: "품절" }); // 재고와 이름 업데이트
updateProduct({ id: 3 }); // id만 (변경 없음)

고수 팁

유틸리티 타입 조합 패턴

// 패턴 1: Partial + Required 조합으로 일부 필드만 필수
type PartialExcept<T, K extends keyof T> = Partial<T> & Required<Pick<T, K>>;

interface Form {
username: string;
email: string;
bio?: string;
avatar?: string;
website?: string;
}

// username과 email은 필수, 나머지는 선택
type CreateProfileForm = PartialExcept<Form, "username" | "email">;

const form1: CreateProfileForm = { username: "alice", email: "a@b.com" }; // OK
const form2: CreateProfileForm = { email: "a@b.com" }; // Error: username 필수

// 패턴 2: Omit + Record로 타입 변환
type ReplaceField<T, K extends keyof T, V> = Omit<T, K> & Record<K, V>;

type UserWithStringId = ReplaceField<User, "id", string>;
// id: string으로 교체, 나머지는 그대로

// 패턴 3: 중첩 유틸리티
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};

const config2: DeepReadonly<ServerConfig> = {
host: "localhost",
port: 3000,
ssl: false,
timeout: 30000,
maxConnections: 100,
cors: { origin: ["*"], credentials: false },
};
config2.cors.origin = []; // Error: readonly

DeepPartial 직접 구현

// 중첩 객체도 모두 Partial로
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;

interface AppState {
user: {
profile: {
name: string;
avatar: string;
};
settings: {
theme: "light" | "dark";
language: string;
notifications: {
email: boolean;
push: boolean;
};
};
};
posts: {
list: Post[];
selectedId: number | null;
};
}

// 깊게 중첩된 구조도 부분 업데이트 가능
type PartialAppState = DeepPartial<AppState>;

const patch: PartialAppState = {
user: {
settings: {
notifications: {
email: false, // 알림 이메일만 끔
},
},
},
};

유틸리티 타입으로 API 계층 타입 설계

// 한 곳에서 관리하는 API 타입 패턴
interface UserEntity {
id: number;
name: string;
email: string;
passwordHash: string;
createdAt: Date;
}

// API 레이어 타입들을 중앙에서 파생
const UserTypes = {
Response: {} as Omit<UserEntity, "passwordHash">,
Create: {} as Omit<UserEntity, "id" | "passwordHash" | "createdAt"> & { password: string },
Update: {} as Partial<Omit<UserEntity, "id" | "passwordHash" | "createdAt">> & { id: number },
List: {} as Pick<UserEntity, "id" | "name" | "email">[],
};

type UserResponse = typeof UserTypes.Response;
type CreateUser = typeof UserTypes.Create;
type UpdateUser = typeof UserTypes.Update;
type UserList = typeof UserTypes.List;

정리 표

유틸리티 타입설명내부 구현
Partial<T>모든 프로퍼티 선택적{ [K in keyof T]?: T[K] }
Required<T>모든 프로퍼티 필수{ [K in keyof T]-?: T[K] }
Readonly<T>모든 프로퍼티 읽기 전용{ readonly [K in keyof T]: T[K] }
Pick<T, K>일부 프로퍼티만 선택{ [P in K]: T[P] }
Omit<T, K>일부 프로퍼티 제외Pick<T, Exclude<keyof T, K>>
Exclude<T, U>유니온에서 타입 제거T extends U ? never : T
Extract<T, U>유니온에서 타입 추출T extends U ? T : never
NonNullable<T>null/undefined 제거T extends null | undefined ? never : T
Record<K, V>키-값 맵 타입{ [P in K]: V }
ReturnType<T>함수 반환 타입 추출조건부 타입 + infer
Parameters<T>함수 매개변수 타입 추출조건부 타입 + infer
Awaited<T>Promise 벗겨낸 타입재귀 조건부 타입

다음 장에서는...

5.4절에서는 조건부 타입(Conditional Types) 을 깊이 다룹니다. T extends U ? X : Y 문법부터 유니온 타입에 자동으로 분배되는 분산 조건부 타입, 조건 내에서 타입을 추출하는 infer 키워드, 그리고 복잡한 유틸리티 타입을 조건부 타입으로 직접 구현하는 방법까지 배웁니다.