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에서 null과 undefined를 제거합니다.
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 키워드, 그리고 복잡한 유틸리티 타입을 조건부 타입으로 직접 구현하는 방법까지 배웁니다.