TypeScript 심화 개념, 실무에서 자주 마주치는 타입들
안녕하세요! 포테코입니다.
TypeScript를 사용하다 보면 기본 타입만으로는 해결하기 어려운 상황을 자주 마주하게 돼요. API 응답 타입을 동적으로 만들거나, 함수의 반환 타입을 유연하게 처리하거나, 타입 안정성을 높이기 위해 더 정교한 타입이 필요할 때가 있어요.
이번 글에서는 제네릭부터 조건부 타입까지 실무에서 자주 사용하는 TypeScript 심화 개념을 실전 예시와 함께 정리했어요. 이론보다는 실제 프로젝트에서 바로 적용할 수 있는 패턴에 집중했습니다.
TypeScript의 심화 개념은 처음에는 어려워 보일 수 있어요. 하지만 하나씩 이해하다 보면 타입 안정성을 크게 높일 수 있고, 코드의 의도를 더 명확하게 표현할 수 있게 됩니다.
제네릭 (Generics), 타입을 변수처럼 사용하기
제네릭은 타입을 변수처럼 사용할 수 있게 해주는 기능이에요. 함수나 클래스에서 다양한 타입을 처리해야 할 때 정말 유용합니다.
기본 사용법
// ❌ 타입을 하드코딩하면 재사용이 어려워요
function getFirstItem(items: number[]): number {
return items[0]
}
function getFirstString(items: string[]): string {
return items[0]
}
// ✅ 제네릭을 사용하면 하나의 함수로 여러 타입 처리
function getFirstItem<T>(items: T[]): T {
return items[0]
}
// 사용
const number = getFirstItem([1, 2, 3]) // number 타입
const string = getFirstItem(['a', 'b', 'c']) // string 타입
const user = getFirstItem([{ id: 1, name: 'John' }]) // { id: number, name: string } 타입실무 예시: API 응답 래퍼
실무에서 API 응답을 처리할 때 제네릭을 활용하면 타입 안정성을 높일 수 있어요.
// API 응답의 공통 구조
interface ApiResponse<T> {
data: T
status: number
message: string
}
// 사용자 정보 타입
interface User {
id: number
name: string
email: string
}
// 제품 정보 타입
interface Product {
id: number
name: string
price: number
}
// API 함수들
async function fetchUser(id: number): Promise<ApiResponse<User>> {
const response = await fetch(`/api/users/${id}`)
return response.json()
}
async function fetchProduct(id: number): Promise<ApiResponse<Product>> {
const response = await fetch(`/api/products/${id}`)
return response.json()
}
// 사용
const userResponse = await fetchUser(1)
console.log(userResponse.data.name) // 타입 안전하게 접근 가능
const productResponse = await fetchProduct(1)
console.log(productResponse.data.price) // 타입 안전하게 접근 가능제네릭 제약 조건
때로는 제네릭 타입에 제약을 두고 싶을 때가 있어요. 예를 들어, 특정 속성을 가진 객체만 받고 싶다면 이렇게 할 수 있어요.
// id 속성을 가진 객체만 허용
function updateItem<T extends { id: number }>(item: T): T {
// id가 있다는 것을 보장받을 수 있어요
console.log(`Updating item with id: ${item.id}`)
return item
}
// ✅ 가능
updateItem({ id: 1, name: 'John' })
updateItem({ id: 2, title: 'Product', price: 1000 })
// ❌ 에러: id 속성이 없음
updateItem({ name: 'John' })여러 제네릭 타입 사용하기
제네릭은 여러 개를 동시에 사용할 수도 있어요.
// 키와 값을 매핑하는 함수
function mapObject<K extends string, V>(
keys: K[],
values: V[]
): Record<K, V> {
const result = {} as Record<K, V>
keys.forEach((key, index) => {
result[key] = values[index]
})
return result
}
// 사용
const mapped = mapObject(['name', 'age'], ['John', 30])
// { name: 'John', age: 30 } 타입타입 가드 (Type Guards), 런타임에서 타입 좁히기
타입 가드는 런타임에서 타입을 좁혀주는 함수예요. typeof, instanceof 같은 연산자보다 더 정교한 타입 체크가 필요할 때 사용합니다.
기본 타입 가드
// 문자열인지 확인하는 타입 가드
function isString(value: unknown): value is string {
return typeof value === 'string'
}
// 사용
function processValue(value: unknown) {
if (isString(value)) {
// 이 블록 안에서는 value가 string 타입으로 좁혀짐
console.log(value.toUpperCase())
}
}객체 타입 가드
API 응답이나 외부 데이터를 처리할 때 객체의 구조를 확인해야 할 때가 많아요.
// User 타입인지 확인하는 타입 가드
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value &&
'email' in value &&
typeof (value as any).id === 'number' &&
typeof (value as any).name === 'string' &&
typeof (value as any).email === 'string'
)
}
// 사용
async function fetchUserData(id: number) {
const response = await fetch(`/api/users/${id}`)
const data = await response.json()
if (isUser(data)) {
// data가 User 타입으로 좁혀짐
console.log(data.name, data.email)
} else {
throw new Error('Invalid user data')
}
}더 간단한 타입 가드 패턴
실무에서는 Zod 같은 라이브러리를 사용하는 경우가 많지만, 간단한 경우에는 직접 타입 가드를 만드는 것도 좋아요.
// 유니온 타입을 좁히는 타입 가드
type ApiResponse =
| { success: true; data: User }
| { success: false; error: string }
function isSuccessResponse(
response: ApiResponse
): response is { success: true; data: User } {
return response.success === true
}
// 사용
function handleResponse(response: ApiResponse) {
if (isSuccessResponse(response)) {
// response.data에 안전하게 접근 가능
console.log(response.data.name)
} else {
// response.error에 안전하게 접근 가능
console.error(response.error)
}
}유틸리티 타입 (Utility Types), 자주 쓰는 타입 변환
TypeScript는 자주 사용하는 타입 변환을 유틸리티 타입으로 제공해요. 직접 타입을 만들기 전에 유틸리티 타입으로 해결할 수 있는지 확인해보세요.
Partial: 모든 속성을 선택적으로
interface User {
id: number
name: string
email: string
age: number
}
// 모든 속성이 선택적이 됨
type PartialUser = Partial<User>
// { id?: number; name?: string; email?: string; age?: number }
// 사용 예시: 업데이트 함수
function updateUser(id: number, updates: Partial<User>) {
// updates는 User의 일부 속성만 포함할 수 있어요
// ...
}
updateUser(1, { name: 'John' }) // ✅ 가능
updateUser(1, { name: 'John', email: 'john@example.com' }) // ✅ 가능Pick: 특정 속성만 선택
// User에서 name과 email만 선택
type UserContact = Pick<User, 'name' | 'email'>
// { name: string; email: string }
// 사용 예시: 프로필 표시용
function displayProfile(user: UserContact) {
console.log(`${user.name} (${user.email})`)
}Omit: 특정 속성 제외
// User에서 id를 제외
type UserWithoutId = Omit<User, 'id'>
// { name: string; email: string; age: number }
// 사용 예시: 새 사용자 생성
function createUser(userData: UserWithoutId): User {
return {
id: generateId(),
...userData,
}
}Record: 키-값 타입 매핑
// 문자열 키와 숫자 값을 가진 객체
type UserScores = Record<string, number>
// { [key: string]: number }
// 더 구체적으로
type UserRole = 'admin' | 'user' | 'guest'
type RolePermissions = Record<UserRole, string[]>
// { admin: string[]; user: string[]; guest: string[] }
const permissions: RolePermissions = {
admin: ['read', 'write', 'delete'],
user: ['read', 'write'],
guest: ['read'],
}실무 예시: API 응답 타입 변환
실무에서 API 응답을 클라이언트에서 사용하기 편한 형태로 변환할 때 유틸리티 타입이 유용해요.
// 서버에서 받는 원본 타입
interface ServerUser {
id: number
user_name: string
user_email: string
created_at: string
}
// 클라이언트에서 사용할 타입
type ClientUser = {
id: ServerUser['id']
name: ServerUser['user_name']
email: ServerUser['user_email']
createdAt: ServerUser['created_at']
}
// 또는 Pick과 Omit을 조합
type ClientUser2 = Pick<ServerUser, 'id'> & {
name: ServerUser['user_name']
email: ServerUser['user_email']
createdAt: ServerUser['created_at']
} & Omit<ServerUser, 'id' | 'user_name' | 'user_email' | 'created_at'>조건부 타입 (Conditional Types), 타입에 따라 분기하기
조건부 타입은 타입 레벨에서 if-else처럼 분기 처리를 할 수 있게 해줘요. 복잡해 보이지만, 실무에서 유용한 패턴이 많습니다.
기본 문법
// T가 string이면 number를, 아니면 string을 반환
type Example<T> = T extends string ? number : string
type A = Example<string> // number
type B = Example<number> // string실무 예시: 함수 반환 타입 추출
API 함수의 반환 타입을 추출하고 싶을 때가 있어요.
// API 함수 타입
type ApiFunction = () => Promise<{ data: User; status: number }>
// Promise의 결과 타입을 추출
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T
// 함수의 반환 타입을 추출
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never
// 조합해서 사용
type ApiResponse = UnwrapPromise<ReturnType<ApiFunction>>
// { data: User; status: number }실무 예시: 배열 타입 추출
배열의 요소 타입을 추출하고 싶을 때도 조건부 타입이 유용해요.
// 배열의 요소 타입 추출
type ArrayElement<T> = T extends (infer U)[] ? U : never
type Numbers = ArrayElement<number[]> // number
type Users = ArrayElement<User[]> // User실무 예시: 옵셔널 체이닝 타입
옵셔널 체이닝을 타입 레벨에서도 처리하고 싶을 때가 있어요.
// 중첩된 객체의 타입을 안전하게 추출
type NestedType<T, K extends string> = K extends keyof T
? T[K] extends object
? T[K]
: never
: never
interface Config {
api: {
baseUrl: string
timeout: number
}
}
type ApiConfig = NestedType<Config, 'api'>
// { baseUrl: string; timeout: number }타입 추론 (Type Inference), TypeScript가 알아서 타입 찾기
TypeScript는 코드를 분석해서 타입을 자동으로 추론해요. 때로는 명시적으로 타입을 지정하는 것보다 추론에 맡기는 것이 더 좋을 수 있어요.
타입 추론이 잘 작동하는 경우
// ✅ 타입을 명시하지 않아도 추론됨
const name = 'John' // string
const age = 30 // number
const isActive = true // boolean
// ✅ 배열도 추론됨
const numbers = [1, 2, 3] // number[]
const users = [{ id: 1, name: 'John' }] // { id: number; name: string }[]
// ✅ 함수 반환 타입도 추론됨
function add(a: number, b: number) {
return a + b // 반환 타입: number
}타입 추론이 어려운 경우
하지만 때로는 타입 추론이 제대로 작동하지 않을 때가 있어요.
// ❌ 타입 추론이 any가 됨
const data = JSON.parse('{"name": "John"}')
// data의 타입이 any
// ✅ 타입 단언 사용
const data = JSON.parse('{"name": "John"}') as { name: string }
// ✅ 또는 타입 가드 사용
function parseUser(json: string): User | null {
try {
const data = JSON.parse(json)
return isUser(data) ? data : null
} catch {
return null
}
}const 단언으로 리터럴 타입 유지
때로는 리터럴 타입을 유지하고 싶을 때가 있어요.
// 일반적으로는 string 타입으로 추론
const theme = 'dark' // string
// as const를 사용하면 리터럴 타입으로 추론
const theme = 'dark' as const // 'dark'
// 객체에도 적용 가능
const config = {
theme: 'dark',
language: 'ko',
} as const
// { readonly theme: 'dark'; readonly language: 'ko' }Mapped Types, 타입을 변환하기
Mapped Types는 기존 타입을 기반으로 새로운 타입을 만들 수 있게 해줘요. 유틸리티 타입의 기반이 되는 개념입니다.
기본 사용법
// 모든 속성을 읽기 전용으로 만들기
type Readonly<T> = {
readonly [P in keyof T]: T[P]
}
// 모든 속성을 선택적으로 만들기
type Partial<T> = {
[P in keyof T]?: T[P]
}실무 예시: API 엔드포인트 타입 정의
실무에서 API 엔드포인트를 타입으로 정의하고 싶을 때 Mapped Types가 유용해요.
// API 엔드포인트 정의
type ApiEndpoints = {
'/users': { GET: User[]; POST: { body: Omit<User, 'id'> } }
'/products': { GET: Product[]; POST: { body: Omit<Product, 'id'> } }
}
// 엔드포인트 타입 추출
type EndpointMethods<T extends keyof ApiEndpoints> = ApiEndpoints[T]
// 사용
type UsersEndpoint = EndpointMethods<'/users'>
// { GET: User[]; POST: { body: Omit<User, 'id'> } }Template Literal Types, 문자열 타입 조작하기
Template Literal Types는 문자열 타입을 조작할 수 있게 해줘요. API 경로나 CSS 클래스명을 타입으로 정의할 때 유용합니다.
기본 사용법
type EventName = 'click' | 'hover' | 'focus'
type HandlerName = `on${Capitalize<EventName>}`
// 'onClick' | 'onHover' | 'onFocus'실무 예시: API 경로 타입
// API 경로를 타입으로 정의
type ApiPath = '/users' | '/products' | '/orders'
type ApiMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
// 경로와 메서드를 조합
type ApiRoute = `${ApiMethod} ${ApiPath}`
// 'GET /users' | 'POST /users' | 'GET /products' | ...
// 사용
function fetchApi(route: ApiRoute) {
// route는 타입 안전하게 사용 가능
}실전 패턴 모음
패턴 1: 타입 안전한 이벤트 핸들러
// 이벤트 타입 정의
type EventMap = {
click: { x: number; y: number }
input: { value: string }
submit: { formData: FormData }
}
// 이벤트 핸들러 타입
type EventHandler<T extends keyof EventMap> = (
event: EventMap[T]
) => void
// 사용
function handleEvent<T extends keyof EventMap>(
type: T,
handler: EventHandler<T>
) {
// 타입 안전하게 이벤트 처리
}패턴 2: 타입 안전한 API 클라이언트
// API 엔드포인트와 메서드 정의
type ApiConfig = {
'/users': {
GET: { response: User[] }
POST: { body: Omit<User, 'id'>; response: User }
}
'/products': {
GET: { response: Product[] }
POST: { body: Omit<Product, 'id'>; response: Product }
}
}
// API 클라이언트 함수
async function apiRequest<
Path extends keyof ApiConfig,
Method extends keyof ApiConfig[Path]
>(
path: Path,
method: Method,
...args: ApiConfig[Path][Method] extends { body: infer B }
? [body: B]
: []
): Promise<ApiConfig[Path][Method] extends { response: infer R } ? R : never> {
// API 호출 로직
const response = await fetch(path, {
method,
body: args[0] ? JSON.stringify(args[0]) : undefined,
})
return response.json()
}
// 사용
const users = await apiRequest('/users', 'GET')
const newUser = await apiRequest('/users', 'POST', { name: 'John', email: 'john@example.com' })패턴 3: 타입 안전한 폼 검증
// 폼 필드 정의
type FormFields = {
name: string
email: string
age: number
}
// 검증 규칙
type ValidationRule<T> = {
required?: boolean
min?: T extends string ? number : never
max?: T extends number ? number : never
pattern?: T extends string ? RegExp : never
}
type FormValidation = {
[K in keyof FormFields]: ValidationRule<FormFields[K]>
}
// 사용
const validation: FormValidation = {
name: { required: true, min: 2 },
email: { required: true, pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ },
age: { required: true, min: 0, max: 120 },
}주의사항 및 베스트 프랙티스
1. 복잡한 타입은 별도로 분리
// ❌ 인라인으로 복잡한 타입 정의
function processData<T extends { id: number } & Record<string, any>>(
data: T
): Partial<Pick<T, 'id'>> & Omit<T, 'id'> {
// ...
}
// ✅ 타입 별칭으로 분리
type ProcessableData<T extends { id: number }> = T
type ProcessedData<T extends { id: number }> = Partial<Pick<T, 'id'>> & Omit<T, 'id'>
function processData<T extends { id: number }>(
data: ProcessableData<T>
): ProcessedData<T> {
// ...
}2. 타입 단언보다 타입 가드 선호
// ❌ 타입 단언 (위험할 수 있음)
const user = data as User
// ✅ 타입 가드 (안전함)
if (isUser(data)) {
const user = data // 타입이 자동으로 좁혀짐
}3. any 사용 최소화
// ❌ any 사용
function processData(data: any) {
return data.value
}
// ✅ unknown 사용
function processData(data: unknown) {
if (typeof data === 'object' && data !== null && 'value' in data) {
return (data as { value: unknown }).value
}
throw new Error('Invalid data')
}마무리
TypeScript의 심화 개념은 처음에는 복잡해 보일 수 있어요. 하지만 하나씩 이해하고 실무에 적용하다 보면 타입 안정성이 크게 향상되고, 코드의 의도도 더 명확해집니다.
모든 심화 개념을 한 번에 마스터하려고 하지 마세요. 프로젝트에서 필요할 때마다 하나씩 학습하고 적용하는 것이 좋아요. 타입 에러를 만날 때마다 "이걸 더 안전하게 만들 수 있을까?"라고 생각해보면 자연스럽게 심화 개념을 익힐 수 있습니다.
다음 단계
- 제네릭부터 시작: 가장 자주 사용하는 개념이에요
- 타입 가드 활용: 런타임 타입 체크로 안정성 높이기
- 유틸리티 타입 활용: 직접 만들기 전에 제공되는 타입 확인
- 점진적 학습: 필요할 때마다 하나씩 익히기
TypeScript의 심화 개념을 활용하면 더 안전하고 유지보수하기 쉬운 코드를 작성할 수 있어요. 앞으로도 TypeScript를 활용한 실전 패턴과 팁을 계속 공유할 예정이니 기대해 주세요!
