본문으로 건너뛰기
Advertisement

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

Advertisement