유틸리티 타입 - 타입을 "코딩" 하라
공식문서 기반의 타입스크립트 입문기
들어가며
사용자 관리 화면에서 항상 맘에 걸리던 건, User타입을 쓰는 곳마다 조금씩 다른 필드 이름과 제약이 나온다는 점이었습니다. Create 화면에서는 전화번호까지 받아서 User를 만들고, Update 화면에서는 id를 제외한 나머지 필드만 바꾸고, API 응답에서는 createdAt, updatedAt이 포함되었습니다. 결국 UserCreateDto, UserUpdateDto, UserResponse를 전부 따로 손으로 쓰다 보니 동일한 필드를 수정할 때마다 복붙, 누락, 일관성 없는 이름으로 런타임에서 NullReference가 잦았습니다.
// JS로 DTO를 따로 적은 모습
function buildUpdatePayload(payload) {
return {
name: payload.name,
email: payload.email,
};
}
function applyUpdate(userId, payload) {
if (payload.name) {
users[userId].name = payload.name;
}
}타입 정의가 없으니, payload에 name이 빠졌는데도 컴파일 타임에서 아무 경고가 없고, 실제 API 응답이 바뀌면 관련 DTO를 전부 수동으로 고쳐야 했습니다. 이 “중복된 타입 선언”이 JS에서 가장 귀찮은 순간이었습니다.
(DTO는 Data Transfer Object의 약자로, 데이터를 전송할 때 사용하는 객체의 구조를 정의한 타입을 말합니다.)
TypeScript의 제안
TypeScript는 유틸리티 타입을 사용해 기존 타입을 조합하고 변형하여 다양한 DTO를 안전하게 생성할 수 있습니다. 사용자 엔티티를 기반으로 하는 다양한 데이터 전송 객체를 효율적으로 관리하는 방법을 제안합니다.
유틸리티 타입으로 DTO 계층화
기본 인터페이스에서 Pick, Omit, Partial을 조합해 다양한 용도의 타입을 파생시킬 수 있습니다.
interface User {
id: string;
name: string;
email: string;
createdAt: string;
updatedAt: string;
}
// 생성용: 시스템이 생성하는 필드 제외
type UserCreateDto = Omit<User, "id" | "createdAt" | "updatedAt">;
// 수정용: 선택적으로 수정 가능한 필드만
type UserUpdateDto = Partial<Pick<User, "name" | "email">>;
// 응답용: 전체 정보
type UserResponse = User;Omit<User, "id" | "createdAt" | "updatedAt">는 User에서 지정된 필드들을 제외한 타입을 만듭니다. Partial<Pick<User, "name" | "email">>은 name과 email 필드만 선택하고 모두 옵셔널로 만듭니다. 이를 통해 기본 엔티티 하나로 다양한 계층의 DTO를 일관되게 관리할 수 있습니다.
심층 분석
1) 속성 선택과 제거의 기본 패턴
Pick과 Omit을 사용해 타입의 속성을 선택적으로 포함하거나 제외할 수 있습니다.
// Pick: 특정 속성만 선택
type UserProfile = Pick<User, "id" | "name" | "email">; // id, name, email만 포함
type UserSettings = Pick<User, "id" | "email">; // id, email만 포함
// Omit: 특정 속성 제외
type UserInput = Omit<User, "id" | "createdAt" | "updatedAt">; // id, createdAt, updatedAt 제외
// 조합 사용
type UpdateProfile = Partial<Pick<User, "name" | "email">>; // name, email을 옵셔널로Pick<T, K>는 타입 T에서 K에 해당하는 속성만을 선택한 새 타입을 만듭니다. Omit<T, K>는 반대로 K에 해당하는 속성을 제외한 타입을 만듭니다. 이를 통해 기본 엔티티에서 다양한 변형 타입을 쉽게 파생시킬 수 있습니다.
2) 옵셔널성과 필수성 제어
Partial과 Required를 사용해 타입의 각 속성이 필수인지 옵셔널인지를 정밀하게 제어할 수 있습니다.
// Partial: 모든 속성을 옵셔널로
type PartialUser = Partial<User>; // 모든 필드가 ?가 됨
// Required: 모든 속성을 필수로
type RequiredUser = Required<User>; // 모든 ?가 제거됨
// 조합 사용: 일부 필수, 일부 옵셔널
type UserUpdateWithId = Required<Pick<User, "id">> & Partial<Pick<User, "name" | "email">>;
// 실제 사용
function updateUser(id: string, updates: Partial<Pick<User, "name" | "email">>) {
// updates.name과 updates.email은 옵셔널
// 하지만 id는 별도로 필수로 받음
}Partial<T>는 T의 모든 속성을 옵셔널로 만듭니다. Required<T>는 반대로 모든 옵셔널 속성을 필수로 만듭니다. 이를 Pick과 조합하면 특정 속성들의 옵셔널성을 정밀하게 제어할 수 있습니다.
3) 유니온 타입과 객체 타입의 고급 조작
Exclude, Extract, Record를 사용해 복잡한 타입 관계를 다룰 수 있습니다.
// 유니온 타입 정의
type UserStatus = "idle" | "active" | "banned";
// Record: 키-값 매핑 객체 타입 생성
type UserMap = Record<string, User>; // { [key: string]: User }
type StatusConfig = Record<UserStatus, string>; // 각 상태별 메시지 매핑
// Exclude: 유니온에서 특정 타입 제외
type ActiveStatus = Exclude<UserStatus, "banned">; // "idle" | "active"
// Extract: 유니온에서 특정 타입만 추출
type AdminStatus = Extract<UserStatus, "idle" | "active">; // "idle" | "active"
// 실제 활용
const statusMessages: Record<UserStatus, string> = {
idle: "대기 중",
active: "활성",
banned: "차단됨",
};
function canAccessAdmin(status: UserStatus): boolean {
return status !== "banned"; // Exclude<UserStatus, "banned">와 동일한 로직
}Record<K, T>는 키 타입 K와 값 타입 T로 구성된 객체 타입을 만듭니다. Exclude<T, U>는 T에서 U에 할당 가능한 타입을 제외합니다. Extract<T, U>는 반대로 T에서 U에 할당 가능한 타입만 추출합니다.
4) 속성 키 추출과 인덱스 접근 (Indexed Access Types)
keyof와 **인덱스 접근 타입(Indexed Access Types)**을 사용해 타입의 속성 정보를 동적으로 다룰 수 있습니다. 인덱스 접근 타입은 T[K] 형태로, 타입 T에서 키 K에 해당하는 속성의 타입을 추출하는 강력한 기능입니다.
// keyof: 타입의 모든 속성 키를 유니온으로
type UserField = keyof User; // "id" | "name" | "email" | "createdAt" | "updatedAt"
// 인덱스 접근 타입 (Indexed Access Types): T[K] 형태로 특정 속성의 타입 추출
type UserEmail = User["email"]; // string - User 타입에서 "email" 속성의 타입
type UserId = User["id"]; // string - User 타입에서 "id" 속성의 타입
type OptionalField = User["createdAt"]; // string (옵셔널이더라도 실제 타입은 유지)
// 제네릭 활용: keyof와 Indexed Access Types의 결합
function getFieldValue<T, K extends keyof T>(obj: T, field: K): T[K] {
// T[K]는 "obj의 field 속성 타입"을 의미 (Indexed Access Types)
return obj[field];
}
// 실제 사용: Indexed Access Types로 타입 안전성 확보
const user: User = {
id: "1",
name: "Kim",
email: "kim@example.com",
createdAt: "2023",
updatedAt: "2024",
};
// getFieldValue(user, "email")의 반환 타입은 User["email"] = string
const email = getFieldValue(user, "email"); // string 타입 (Indexed Access Types로 추론)
const name = getFieldValue(user, "name"); // string 타입 (동일)
// 컴파일 오류: 존재하지 않는 필드 (keyof User에 포함되지 않음)
// const invalid = getFieldValue(user, "invalidField");keyof T는 타입 T의 모든 속성 키를 유니온 타입으로 만들어줍니다. T[K]는 **인덱스 접근 타입(Indexed Access Types)**으로, T의 K 속성에 해당하는 타입을 추출합니다. 이를 함께 사용하면 런타임에서 발생할 수 있는 속성 접근 오류를 컴파일 타임에 방지할 수 있습니다.
5) satisfies 연산자로 타입 안전성 강화
satisfies 연산자는 값이 특정 타입을 만족하는지 확인하면서도 원래의 리터럴 타입 정보를 유지합니다.
// ❌ as const 없이 일반 객체 사용 시
const config1 = {
theme: "dark",
language: "ko",
};
type ThemeType1 = keyof typeof config1; // string (너무 넓음)
// ✅ satisfies로 타입 확인 + 리터럴 유지
const config2 = {
theme: "dark",
language: "ko",
} satisfies Record<string, string>;
type ThemeType2 = keyof typeof config2; // "theme" | "language" (정확함)
// 실제 활용
const themeConfig = {
primary: "#007bff",
secondary: "#6c757d",
danger: "#dc3545",
} satisfies Record<string, string>;
type ThemeKey = keyof typeof themeConfig; // "primary" | "secondary" | "danger"
// 타입 안전한 테마 접근 함수
function getThemeColor(key: ThemeKey): string {
return themeConfig[key];
}
getThemeColor("primary"); // ✅
getThemeColor("invalid"); // ❌ 컴파일 오류satisfies는 as const와 달리 값의 구조를 변경하지 않으면서 타입 검증을 수행합니다. 런타임에서는 일반 객체로 동작하지만, 타입 시스템에서는 정확한 키 정보를 유지합니다.
실전 패턴 (In React)
폼 상태 관리와 유틸리티 타입 통합
React 컴포넌트에서 유틸리티 타입을 활용해 폼 상태를 타입 안전하게 관리하는 방법을 보여줍니다.
// 1) 타입 정의: 폼과 API 호출에 사용할 타입들 준비
// FormDto: name과 email 필드만 선택하고 모두 옵셔널로 (Partial<Pick<User, "name" | "email">>)
// - Pick으로 name, email만 선택
// - Partial로 옵셔널 필드로 만듦 (수정 시 일부만 변경 가능)
type FormDto = Partial<Pick<User, "name" | "email">>;
// UserUpdateWithId: API 호출용 타입
// - Required<Pick<User, "id">>: id는 반드시 필요
// - & FormDto: 폼 데이터와 결합
type UserUpdateWithId = Required<Pick<User, "id">> & FormDto;
function UserForm({ user }: { user: User }) {
// 2) 폼 상태 초기화: 빈 객체로 시작 (모든 필드가 옵셔널이므로)
// FormDto 타입으로 타입 안전성 보장
const [form, setForm] = useState<FormDto>({});
// 3) 폼 필드 업데이트 함수: 동적 필드명으로 상태 업데이트
// keyof FormDto로 "name" | "email" 유니온 타입 보장
function updateForm(field: keyof FormDto, value: string) {
setForm((prev) => ({ ...prev, [field]: value }));
}
// 4) 제출 핸들러: 폼 데이터를 API 호출용 DTO로 변환
function handleSubmit() {
// UserUpdateWithId 타입으로 변환: id(필수) + 폼 데이터(옵셔널)
// { id: user.id, ...form } → { id: string, name?: string, email?: string }
const dto: UserUpdateWithId = { id: user.id, ...form };
// 타입 안전한 API 호출
api.updateUser(dto);
}
return (
<div>
{/* 5) 이름 입력: form.name이 undefined일 수 있으므로 ?? ""로 처리 */}
<input
value={form.name ?? ""} // FormDto에서 name은 옵셔널
onChange={(e) => updateForm("name", e.target.value)} // "name" 키로 업데이트
placeholder="이름"
/>
{/* 6) 이메일 입력: 동일한 패턴 */}
<input
value={form.email ?? ""} // FormDto에서 email은 옵셔널
onChange={(e) => updateForm("email", e.target.value)} // "email" 키로 업데이트
placeholder="이메일"
/>
{/* 7) 제출 버튼: handleSubmit에서 타입 변환 후 API 호출 */}
<button onClick={handleSubmit}>수정</button>
</div>
);
}FormDto는 폼에서 편집 가능한 필드만을 옵셔널로 정의합니다. keyof FormDto를 사용해 동적 필드 업데이트 함수를 만들고, handleSubmit에서 UserUpdateWithId 타입으로 변환하여 API 호출 시 필수 필드(id)를 보장합니다.
함정
- 모든 DTO를
Pick,Omit으로 만든다고 해서 무조건 좋은 건 아닙니다. 명확한 목적이 있는 타입만 파생시키고, 지나치게 여러 조합을 만들면 오히려 추적이 어려워집니다. Partial을 남용하면 “어떤 필드가 진짜 필수인지” 잊어버립니다. 주석이나 타입 별칭으로 의도를 기록하세요.Record,Exclude등을 사용할 때는 타입 간의 관계를 문서화해야 합니다. JS에서는 그냥 객체를 만들면 되지만, TS에서는 타입이 서로 의존하게 되어 변화에 민감합니다.satisfies없이 값만 사용할 경우,keyof가string/number로 넓어지는 일이 생깁니다.as const로 literal을 유지하거나satisfies로 타입을 붙여 두는 습관을 들이세요. (satisfies는 값의 구조를 유지하면서 타입 검증을 수행하는 TypeScript 4.9+ 연산자입니다.)
예상 질문
Q1. Omit을 쓰면 User에서 무슨 필드를 뺐는지 한눈에 안 보일 텐데요?
A: Omit<User, "id">처럼 이름에 필드 이름이 들어가므로, 오히려 어떤 필드를 빼는지 명시적으로 남깁니다. type UserCreateDto = Omit<User, "id"> 하나면 모든 변경 이력이 따라옵니다.
Q2. Partial을 쓰면 모든 필드가 옵셔널이 되는데, 실수할 수 있지 않나요?
A: Partial은 DTO를 구성하는 중간 단계입니다. 최종 API 호출 시에는 Required<Pick<...>>처럼 필수 항목을 다시 선언하거나, as const와 satisfies를 활용해 필수 필드를 검증할 수 있습니다.
Q3. 이렇게 타입을 많이 만들면 TS 컴파일 시간이 느려지지 않나요?
A: 기본 유틸리티 타입들은 컴파일러가 내부적으로 캐싱해서 처리하므로 일반적인 CRUD 레벨에서는 큰 문제 없습니다. 대신 무한대로 깊게 infer/extends를 중첩하면 느려질 수 있으니, 그럴 때만 별도 타입 별칭으로 한 번만 정의하세요.
요약
타입을 "코드처럼 다루는" 유틸리티 타입은, DTO를 반복해서 쓰는 JS에서 가장 눈에 띄는 불편함들을 해결합니다. Pick, Omit, Partial과 Indexed Access를 조합하면 한 번 정의한 User에서 필요한 조합을 안전하게 꺼내 쓸 수 있고, satisfies 연산자로 런타임 값의 타입 안전성을 유지할 수 있습니다. React 컴포넌트에서도 타입 흐름이 깔끔해집니다. 다음 편에서는 이 유틸리티 타입을 더 일반화하여 "타입을 만드는 타입(Mapped Types)"으로 확장합니다.
참조
- TypeScript 공식문서