Skip to content
Published on

TypeScript 고급 패턴 완전 정복: 제네릭부터 타입 체조까지 실전 가이드 2025

Authors

들어가며

TypeScript는 이제 JavaScript 생태계의 표준이 되었습니다. 2024-2025년 동안 TypeScript 5.4부터 5.7까지 빠르게 진화하면서, 타입 시스템의 표현력은 더욱 강력해졌습니다. 단순히 타입을 붙이는 수준을 넘어, 타입 레벨에서 로직을 구현하고, 런타임 안전성까지 보장하는 것이 현대 TypeScript 개발의 핵심입니다.

이 글에서는 제네릭의 기초부터 Conditional Types, Mapped Types, Template Literal Types, 브랜디드 타입, Zod + tRPC 통합, 그리고 타입 체조(Type Challenges)까지 TypeScript 고급 패턴을 체계적으로 정리합니다. 각 섹션에는 실전에서 바로 적용할 수 있는 코드 예제가 포함되어 있습니다.


1. TypeScript 5.x 새로운 기능 (2024-2025)

TypeScript 5.4 — NoInfer와 groupBy

TypeScript 5.4에서 가장 주목할 기능은 NoInfer 유틸리티 타입입니다. 제네릭 함수에서 특정 매개변수가 타입 추론에 영향을 주지 않도록 제어할 수 있습니다.

// NoInfer 없이 — defaultValue가 T 추론에 영향을 줌
function getOrDefault<T>(value: T | undefined, defaultValue: T): T {
  return value ?? defaultValue
}

// NoInfer 사용 — defaultValue는 T 추론에 참여하지 않음
function getOrDefault<T>(value: T | undefined, defaultValue: NoInfer<T>): T {
  return value ?? defaultValue
}

// 사용 예시
const result = getOrDefault('hello', 42)
// NoInfer 없이: T = string | number
// NoInfer 있으면: T = string, 42에서 에러 발생

Object.groupByMap.groupBy도 정식 지원됩니다.

const users = [
  { name: 'Alice', role: 'admin' },
  { name: 'Bob', role: 'user' },
  { name: 'Charlie', role: 'admin' },
]

const grouped = Object.groupBy(users, (user) => user.role)
// 타입: Partial<Record<string, User[]>>

TypeScript 5.5 — Inferred Type Predicates

TypeScript 5.5에서는 타입 가드를 명시적으로 선언하지 않아도 컴파일러가 자동으로 타입 좁히기를 추론합니다.

// 이전: 명시적 타입 가드 필요
function isString(value: unknown): value is string {
  return typeof value === 'string'
}

// TS 5.5: 자동 추론
const values = [1, 'hello', null, 'world', 3]
const strings = values.filter((v) => typeof v === 'string')
// TS 5.5에서 strings의 타입은 string[] (이전에는 (string | number | null)[])

isolatedDeclarations 옵션은 .d.ts 생성을 외부 도구(SWC, oxc 등)에 위임할 수 있게 합니다.

{
  "compilerOptions": {
    "isolatedDeclarations": true,
    "declaration": true
  }
}

TypeScript 5.6 — Iterator Helpers

ECMAScript Iterator Helpers 제안을 지원하여 이터레이터를 체이닝 방식으로 조작할 수 있습니다.

function* fibonacci(): Generator<number> {
  let a = 0,
    b = 1
  while (true) {
    yield a
    ;[a, b] = [b, a + b]
  }
}

// Iterator helpers (TS 5.6)
const first10Even = fibonacci()
  .filter((n) => n % 2 === 0)
  .take(10)
  .toArray()

--noUncheckedSideEffectImports 플래그는 사이드 이펙트 임포트의 존재 여부를 검증합니다.

// 이 파일이 존재하지 않으면 에러 발생
import './polyfills.js'
import 'some-module/register'

TypeScript 5.7 — ES2024 타겟과 경로 재작성

{
  "compilerOptions": {
    "target": "es2024",
    "rewriteRelativeImportExtensions": true
  }
}

rewriteRelativeImportExtensions 옵션은 .ts 임포트를 출력 시 .js로 자동 변환합니다.

// 소스 코드
import { helper } from './utils.ts'

// 컴파일 후 출력
import { helper } from './utils.js'

2. 제네릭 마스터 클래스

기본 제네릭

제네릭은 타입을 매개변수로 받아 재사용 가능한 코드를 작성하는 TypeScript의 핵심 기능입니다.

// 가장 기본적인 제네릭 함수
function identity<T>(arg: T): T {
  return arg
}

// 제네릭 인터페이스
interface Box<T> {
  value: T
  map<U>(fn: (value: T) => U): Box<U>
}

function createBox<T>(value: T): Box<T> {
  return {
    value,
    map: (fn) => createBox(fn(value)),
  }
}

const numberBox = createBox(42) // Box<number>
const stringBox = numberBox.map(String) // Box<string>

제약 조건 (extends)

// 기본 제약 조건
function getLength<T extends { length: number }>(arg: T): number {
  return arg.length
}

getLength('hello') // OK
getLength([1, 2, 3]) // OK
getLength(42) // 에러: number에 length 없음

// keyof 제약 조건
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key]
}

const user = { name: 'Alice', age: 30 }
getProperty(user, 'name') // string
getProperty(user, 'age') // number
getProperty(user, 'foo') // 에러: 'foo'는 keyof User가 아님

제네릭 기본값

interface PaginatedResponse<T, Meta = { page: number; total: number }> {
  data: T[]
  meta: Meta
}

// Meta 기본값 사용
const response: PaginatedResponse<User> = {
  data: [{ name: 'Alice', age: 30 }],
  meta: { page: 1, total: 100 },
}

// Meta 커스터마이즈
const cursorResponse: PaginatedResponse<User, { cursor: string; hasMore: boolean }> = {
  data: [{ name: 'Bob', age: 25 }],
  meta: { cursor: 'abc123', hasMore: true },
}

실전 예제: API 응답 래퍼

// API 응답 타입 체계
interface ApiResponse<T> {
  success: true
  data: T
  timestamp: number
}

interface ApiError {
  success: false
  error: {
    code: string
    message: string
  }
  timestamp: number
}

type ApiResult<T> = ApiResponse<T> | ApiError

// 타입 안전한 API 클라이언트
async function fetchApi<T>(url: string): Promise<ApiResult<T>> {
  const response = await fetch(url)
  return response.json()
}

// 사용
interface User {
  id: number
  name: string
  email: string
}

const result = await fetchApi<User>('/api/users/1')

if (result.success) {
  console.log(result.data.name) // 타입 안전: User
} else {
  console.error(result.error.message) // 타입 안전: ApiError
}

실전 예제: Repository 패턴

interface Entity {
  id: string
  createdAt: Date
  updatedAt: Date
}

interface Repository<T extends Entity> {
  findById(id: string): Promise<T | null>
  findMany(filter: Partial<T>): Promise<T[]>
  create(data: Omit<T, 'id' | 'createdAt' | 'updatedAt'>): Promise<T>
  update(id: string, data: Partial<Omit<T, 'id' | 'createdAt' | 'updatedAt'>>): Promise<T>
  delete(id: string): Promise<void>
}

// 구현
interface User extends Entity {
  name: string
  email: string
  role: 'admin' | 'user'
}

class UserRepository implements Repository<User> {
  async findById(id: string): Promise<User | null> {
    // DB 쿼리
    return null
  }

  async findMany(filter: Partial<User>): Promise<User[]> {
    return []
  }

  async create(data: Omit<User, 'id' | 'createdAt' | 'updatedAt'>): Promise<User> {
    // data는 { name: string; email: string; role: 'admin' | 'user' } 타입
    return {} as User
  }

  async update(
    id: string,
    data: Partial<Omit<User, 'id' | 'createdAt' | 'updatedAt'>>
  ): Promise<User> {
    return {} as User
  }

  async delete(id: string): Promise<void> {}
}

실전 예제: Builder 패턴

class QueryBuilder<T extends Record<string, unknown>> {
  private conditions: string[] = []
  private selectedFields: string[] = []

  select<K extends keyof T>(...fields: K[]): QueryBuilder<Pick<T, K>> {
    this.selectedFields = fields as string[]
    return this as unknown as QueryBuilder<Pick<T, K>>
  }

  where<K extends keyof T>(field: K, value: T[K]): this {
    this.conditions.push(`${String(field)} = ${JSON.stringify(value)}`)
    return this
  }

  build(): { fields: string[]; conditions: string[] } {
    return {
      fields: this.selectedFields,
      conditions: this.conditions,
    }
  }
}

// 사용
interface Product {
  id: number
  name: string
  price: number
  category: string
}

const query = new QueryBuilder<Product>()
  .select('name', 'price')
  .where('category', 'electronics')
  .build()

제네릭 추론 (infer) 심화

// 함수 반환 타입 추출
type ReturnTypeOf<T> = T extends (...args: any[]) => infer R ? R : never

// Promise 내부 타입 추출
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T

// 배열 요소 타입 추출
type ElementOf<T> = T extends (infer E)[] ? E : never

// 실전: 여러 infer를 함께 사용
type FunctionParts<T> = T extends (...args: infer A) => infer R ? { args: A; return: R } : never

type Parts = FunctionParts<(name: string, age: number) => boolean>
// { args: [string, number]; return: boolean }

3. Conditional Types 심화

infer 키워드로 타입 추출

Conditional Types의 infer 키워드는 타입을 "캡처"하는 역할을 합니다. 복잡한 타입에서 원하는 부분을 추출할 때 필수적입니다.

// 객체에서 특정 키의 값 타입 추출
type ValueOf<T, K extends keyof T> = T[K]

// 함수 첫 번째 매개변수 타입 추출
type FirstParam<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never

type P = FirstParam<(name: string, age: number) => void> // string

// 생성자의 인스턴스 타입 추출
type InstanceOf<T> = T extends new (...args: any[]) => infer I ? I : never

class MyClass {
  value = 42
}

type Instance = InstanceOf<typeof MyClass> // MyClass

재귀 Conditional Types

// 깊은 Readonly
type DeepReadonly<T> = T extends (infer U)[]
  ? ReadonlyArray<DeepReadonly<U>>
  : T extends object
    ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
    : T

interface Config {
  db: {
    host: string
    port: number
    credentials: {
      user: string
      password: string
    }
  }
  features: string[]
}

type ReadonlyConfig = DeepReadonly<Config>
// 모든 중첩 프로퍼티가 readonly가 됨

// 깊은 Partial
type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T

// JSON 타입 (재귀)
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }

Distributive Conditional Types

유니온 타입에 Conditional Types를 적용하면, 각 멤버에 대해 개별적으로 적용됩니다.

// 분배 법칙이 적용되는 경우
type ToArray<T> = T extends any ? T[] : never

type Result = ToArray<string | number>
// string[] | number[] (분배됨)

// 분배를 방지하려면 대괄호로 감싸기
type ToArrayNonDist<T> = [T] extends [any] ? T[] : never

type Result2 = ToArrayNonDist<string | number>
// (string | number)[] (분배 안 됨)

실전: API 응답에서 데이터 타입 추출

// API 엔드포인트 타입 정의
interface ApiEndpoints {
  '/users': { response: User[]; params: never }
  '/users/:id': { response: User; params: { id: string } }
  '/posts': { response: Post[]; params: never }
  '/posts/:id': { response: Post; params: { id: string } }
}

// 응답 타입 추출
type ResponseOf<T extends keyof ApiEndpoints> = ApiEndpoints[T]['response']
type ParamsOf<T extends keyof ApiEndpoints> = ApiEndpoints[T]['params']

// 사용
type UserListResponse = ResponseOf<'/users'> // User[]
type UserParams = ParamsOf<'/users/:id'> // { id: string }

Promise Unwrap (깊은 래핑 해제)

type DeepAwaited<T> = T extends Promise<infer U> ? DeepAwaited<U> : T

type A = DeepAwaited<Promise<Promise<Promise<string>>>> // string

// 실전 활용: 비동기 함수의 실제 반환 타입
type AsyncReturnType<T extends (...args: any[]) => Promise<any>> = T extends (
  ...args: any[]
) => Promise<infer R>
  ? R
  : never

async function fetchUser(): Promise<User> {
  return {} as User
}

type FetchedUser = AsyncReturnType<typeof fetchUser> // User

4. Mapped Types와 Template Literal Types

내장 유틸리티 타입 직접 구현

TypeScript의 내장 유틸리티 타입이 어떻게 동작하는지 이해하면 커스텀 유틸리티를 만들 수 있습니다.

// Partial 구현
type MyPartial<T> = {
  [K in keyof T]?: T[K]
}

// Required 구현
type MyRequired<T> = {
  [K in keyof T]-?: T[K]
}

// Readonly 구현
type MyReadonly<T> = {
  readonly [K in keyof T]: T[K]
}

// Pick 구현
type MyPick<T, K extends keyof T> = {
  [P in K]: T[P]
}

// Omit 구현
type MyOmit<T, K extends keyof T> = {
  [P in keyof T as P extends K ? never : P]: T[P]
}

Key Remapping (as)

TypeScript 4.1+에서 as 절을 사용하면 키를 변환할 수 있습니다.

// 모든 키에 접두사 추가
type Prefixed<T, Prefix extends string> = {
  [K in keyof T as `${Prefix}${Capitalize<string & K>}`]: T[K]
}

interface User {
  name: string
  age: number
}

type PrefixedUser = Prefixed<User, 'get'>
// { getName: string; getAge: number }

// Getter 타입 자동 생성
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}

type UserGetters = Getters<User>
// { getName: () => string; getAge: () => number }

// 특정 타입의 키만 필터링
type StringKeys<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K]
}

interface Mixed {
  name: string
  age: number
  email: string
  active: boolean
}

type OnlyStrings = StringKeys<Mixed>
// { name: string; email: string }

Template Literal Types

문자열 리터럴 타입을 조합하여 강력한 타입을 생성할 수 있습니다.

// 기본 템플릿 리터럴
type EventName = 'click' | 'focus' | 'blur'
type EventHandler = `on${Capitalize<EventName>}`
// 'onClick' | 'onFocus' | 'onBlur'

// CSS 단위
type CSSUnit = 'px' | 'em' | 'rem' | '%' | 'vh' | 'vw'
type CSSValue = `${number}${CSSUnit}`

const width: CSSValue = '100px' // OK
const height: CSSValue = '50vh' // OK

// 내장 문자열 조작 타입
type Upper = Uppercase<'hello'> // 'HELLO'
type Lower = Lowercase<'HELLO'> // 'hello'
type Cap = Capitalize<'hello'> // 'Hello'
type Uncap = Uncapitalize<'Hello'> // 'hello'

실전: 이벤트 핸들러 타입 자동 생성

interface Events {
  userCreated: { userId: string; name: string }
  userUpdated: { userId: string; changes: Record<string, unknown> }
  orderPlaced: { orderId: string; total: number }
  orderCancelled: { orderId: string; reason: string }
}

// 이벤트 리스너 타입 자동 생성
type EventListeners<T> = {
  [K in keyof T as `on${Capitalize<string & K>}`]: (payload: T[K]) => void
}

type AppEventListeners = EventListeners<Events>
// {
//   onUserCreated: (payload: { userId: string; name: string }) => void;
//   onUserUpdated: (payload: { userId: string; changes: Record<string, unknown> }) => void;
//   onOrderPlaced: (payload: { orderId: string; total: number }) => void;
//   onOrderCancelled: (payload: { orderId: string; reason: string }) => void;
// }

실전: API Route 타입 자동 생성

// REST API 메서드 정의
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'

type ApiRoute<Method extends HttpMethod, Path extends string, Body = never, Response = unknown> = {
  method: Method
  path: Path
  body: Body
  response: Response
}

// 라우트 정의
type Routes = {
  listUsers: ApiRoute<'GET', '/api/users', never, User[]>
  getUser: ApiRoute<'GET', '/api/users/:id', never, User>
  createUser: ApiRoute<'POST', '/api/users', Omit<User, 'id'>, User>
  updateUser: ApiRoute<'PUT', '/api/users/:id', Partial<User>, User>
  deleteUser: ApiRoute<'DELETE', '/api/users/:id', never, void>
}

// 타입 안전한 클라이언트 생성
type TypeSafeClient<R extends Record<string, ApiRoute<any, any, any, any>>> = {
  [K in keyof R]: R[K]['body'] extends never
    ? () => Promise<R[K]['response']>
    : (body: R[K]['body']) => Promise<R[K]['response']>
}

type Client = TypeSafeClient<Routes>
// {
//   listUsers: () => Promise<User[]>;
//   getUser: () => Promise<User>;
//   createUser: (body: Omit<User, 'id'>) => Promise<User>;
//   updateUser: (body: Partial<User>) => Promise<User>;
//   deleteUser: () => Promise<void>;
// }

5. Branded Types와 Phantom Types

문제: 구조적 타이핑의 한계

TypeScript는 구조적 타이핑(structural typing)을 사용합니다. 구조가 같으면 같은 타입으로 취급됩니다.

// 문제 상황
type UserId = string
type PostId = string

function deleteUser(id: UserId): void {}
function deletePost(id: PostId): void {}

const userId: UserId = 'user-123'
const postId: PostId = 'post-456'

// 둘 다 string이므로 실수로 바꿔 넣어도 에러 없음!
deleteUser(postId) // 에러가 발생해야 하는데 통과됨
deletePost(userId) // 이것도 통과됨

해결: Branded Types

// 브랜드 심볼을 추가하여 구조적으로 구분
type Brand<T, B extends string> = T & { readonly __brand: B }

type UserId = Brand<string, 'UserId'>
type PostId = Brand<string, 'PostId'>
type OrderId = Brand<string, 'OrderId'>

// 생성 함수
function createUserId(id: string): UserId {
  return id as UserId
}

function createPostId(id: string): PostId {
  return id as PostId
}

// 이제 타입 안전!
function deleteUser(id: UserId): void {}
function deletePost(id: PostId): void {}

const userId = createUserId('user-123')
const postId = createPostId('post-456')

deleteUser(userId) // OK
deleteUser(postId) // 에러! PostId는 UserId에 할당할 수 없음

Currency 타입 안전성

type Currency<C extends string> = number & { readonly __currency: C }

type USD = Currency<'USD'>
type EUR = Currency<'EUR'>
type KRW = Currency<'KRW'>

function usd(amount: number): USD {
  return amount as USD
}

function eur(amount: number): EUR {
  return amount as EUR
}

function addUSD(a: USD, b: USD): USD {
  return (a + b) as USD
}

const price1 = usd(100)
const price2 = usd(50)
const priceEur = eur(80)

addUSD(price1, price2) // OK
addUSD(price1, priceEur) // 에러! EUR는 USD에 할당할 수 없음

데이터베이스 ID 타입 분리

// 제네릭 브랜디드 ID 시스템
type EntityId<Entity extends string> = Brand<string, Entity>

type UserId = EntityId<'User'>
type PostId = EntityId<'Post'>
type CommentId = EntityId<'Comment'>

// 타입 안전한 관계 정의
interface Post {
  id: PostId
  authorId: UserId
  title: string
  content: string
}

interface Comment {
  id: CommentId
  postId: PostId
  authorId: UserId
  text: string
}

// 리포지토리에서 활용
function findPostsByAuthor(authorId: UserId): Promise<Post[]> {
  return Promise.resolve([])
}

function findCommentsByPost(postId: PostId): Promise<Comment[]> {
  return Promise.resolve([])
}

// 컴파일 타임에 잘못된 ID 사용을 방지
const userId = 'user-1' as UserId
const postId = 'post-1' as PostId

findPostsByAuthor(userId) // OK
findPostsByAuthor(postId) // 에러!

Zod와의 결합

import { z } from 'zod'

// Zod 스키마에 브랜디드 타입 적용
const UserIdSchema = z.string().uuid().brand('UserId')
type UserId = z.infer<typeof UserIdSchema>

const PostIdSchema = z.string().uuid().brand('PostId')
type PostId = z.infer<typeof PostIdSchema>

// 런타임 검증 + 타입 안전
const userId = UserIdSchema.parse('550e8400-e29b-41d4-a716-446655440000')
// userId의 타입은 string & Brand<'UserId'>

// 잘못된 형식이면 런타임에서 에러 발생
try {
  const invalid = UserIdSchema.parse('not-a-uuid')
} catch (e) {
  console.error('유효하지 않은 UserId')
}

6. 타입 안전 패턴 10선

패턴 1: Discriminated Unions for State Machines

// 상태 머신을 타입으로 표현
type RequestState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error }

function renderUser(state: RequestState<User>): string {
  switch (state.status) {
    case 'idle':
      return '대기 중...'
    case 'loading':
      return '로딩 중...'
    case 'success':
      return `사용자: ${state.data.name}`
    case 'error':
      return `에러: ${state.error.message}`
  }
}

// 유한 상태 머신
type TrafficLight =
  | { state: 'red'; timer: number }
  | { state: 'yellow'; timer: number }
  | { state: 'green'; timer: number }

function nextState(light: TrafficLight): TrafficLight {
  switch (light.state) {
    case 'red':
      return { state: 'green', timer: 30 }
    case 'green':
      return { state: 'yellow', timer: 5 }
    case 'yellow':
      return { state: 'red', timer: 30 }
  }
}

패턴 2: Exhaustive Checking with never

// 모든 케이스를 처리했는지 컴파일 타임에 검증
function assertNever(value: never): never {
  throw new Error(`Unexpected value: ${value}`)
}

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'rectangle'; width: number; height: number }
  | { kind: 'triangle'; base: number; height: number }

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2
    case 'rectangle':
      return shape.width * shape.height
    case 'triangle':
      return (shape.base * shape.height) / 2
    default:
      return assertNever(shape)
    // 새로운 Shape 추가 시 여기서 컴파일 에러 발생
  }
}

패턴 3: Type-safe Event Emitter

type EventMap = Record<string, any>

class TypedEventEmitter<T extends EventMap> {
  private listeners: Partial<Record<keyof T, Function[]>> = {}

  on<K extends keyof T>(event: K, listener: (payload: T[K]) => void): void {
    if (!this.listeners[event]) {
      this.listeners[event] = []
    }
    this.listeners[event]!.push(listener)
  }

  emit<K extends keyof T>(event: K, payload: T[K]): void {
    this.listeners[event]?.forEach((fn) => fn(payload))
  }

  off<K extends keyof T>(event: K, listener: (payload: T[K]) => void): void {
    this.listeners[event] = this.listeners[event]?.filter((fn) => fn !== listener)
  }
}

// 사용
interface AppEvents {
  userLoggedIn: { userId: string; timestamp: Date }
  orderPlaced: { orderId: string; total: number }
  error: { message: string; stack?: string }
}

const emitter = new TypedEventEmitter<AppEvents>()

emitter.on('userLoggedIn', (payload) => {
  console.log(payload.userId) // 타입 안전: string
})

emitter.emit('orderPlaced', { orderId: '123', total: 99.99 }) // OK
emitter.emit('orderPlaced', { orderId: '123' }) // 에러: total 누락

패턴 4: Builder Pattern with Type Accumulation

// 필수 필드를 타입 레벨에서 추적하는 빌더
type RequiredFields = 'host' | 'port' | 'database'

class ConnectionBuilder<Built extends string = never> {
  private config: Record<string, unknown> = {}

  host(value: string): ConnectionBuilder<Built | 'host'> {
    this.config.host = value
    return this as any
  }

  port(value: number): ConnectionBuilder<Built | 'port'> {
    this.config.port = value
    return this as any
  }

  database(value: string): ConnectionBuilder<Built | 'database'> {
    this.config.database = value
    return this as any
  }

  username(value: string): ConnectionBuilder<Built> {
    this.config.username = value
    return this as any
  }

  // 모든 필수 필드가 설정되었을 때만 build 가능
  build(this: ConnectionBuilder<RequiredFields>): Record<string, unknown> {
    return this.config
  }
}

// 사용
new ConnectionBuilder().host('localhost').port(5432).database('mydb').build() // OK

new ConnectionBuilder().host('localhost').port(5432).build() // 에러! database가 누락됨

패턴 5: Const Assertions와 Readonly Tuples

// as const로 리터럴 타입 보존
const ROLES = ['admin', 'editor', 'viewer'] as const
type Role = (typeof ROLES)[number] // 'admin' | 'editor' | 'viewer'

// 설정 객체에 활용
const CONFIG = {
  api: {
    baseUrl: 'https://api.example.com',
    timeout: 5000,
    retries: 3,
  },
  features: {
    darkMode: true,
    notifications: false,
  },
} as const

type Config = typeof CONFIG
// 모든 값이 리터럴 타입으로 고정됨

// 라우팅 테이블
const routes = {
  home: '/',
  about: '/about',
  users: '/users',
  userDetail: '/users/:id',
} as const

type RoutePath = (typeof routes)[keyof typeof routes]
// '/' | '/about' | '/users' | '/users/:id'

패턴 6: Variadic Tuple Types

// 튜플 타입 결합
type Concat<A extends unknown[], B extends unknown[]> = [...A, ...B]

type AB = Concat<[1, 2], [3, 4]> // [1, 2, 3, 4]

// 함수 매개변수 결합
type MergeParams<F1 extends (...args: any[]) => any, F2 extends (...args: any[]) => any> = (
  ...args: [...Parameters<F1>, ...Parameters<F2>]
) => ReturnType<F2>

// pipe 함수 타입
function pipe<A, B>(fn1: (a: A) => B): (a: A) => B
function pipe<A, B, C>(fn1: (a: A) => B, fn2: (b: B) => C): (a: A) => C
function pipe<A, B, C, D>(fn1: (a: A) => B, fn2: (b: B) => C, fn3: (c: C) => D): (a: A) => D
function pipe(...fns: Function[]) {
  return (x: any) => fns.reduce((acc, fn) => fn(acc), x)
}

const transform = pipe(
  (x: string) => x.length,
  (x: number) => x * 2,
  (x: number) => x.toString()
)

const result = transform('hello') // '10'

패턴 7: Type-safe ORM Queries

// 타입 안전한 WHERE 조건
type WhereClause<T> = {
  [K in keyof T]?: T[K] | { eq: T[K] } | { ne: T[K] } | { gt: T[K] } | { lt: T[K] } | { in: T[K][] }
}

type OrderBy<T> = {
  [K in keyof T]?: 'asc' | 'desc'
}

interface QueryOptions<T> {
  where?: WhereClause<T>
  orderBy?: OrderBy<T>
  limit?: number
  offset?: number
}

function findMany<T>(table: string, options: QueryOptions<T>): Promise<T[]> {
  return Promise.resolve([])
}

// 사용
interface Product {
  id: number
  name: string
  price: number
  category: string
}

findMany<Product>('products', {
  where: {
    price: { gt: 100 },
    category: { in: ['electronics', 'books'] },
  },
  orderBy: { price: 'desc' },
  limit: 10,
})

패턴 8: Recursive Types (JSON, Tree)

// JSON 타입
type Json = string | number | boolean | null | Json[] | { [key: string]: Json }

// 타입 안전한 트리 구조
interface TreeNode<T> {
  value: T
  children: TreeNode<T>[]
}

// 경로를 추적하는 타입 안전한 트리 탐색
type TreePath<T> = T extends { children: (infer C)[] }
  ? C extends { value: infer V }
    ? V | TreePath<C>
    : never
  : never

// 중첩 객체의 경로 타입
type NestedKeyOf<T extends object> = {
  [K in keyof T & string]: T[K] extends object ? K | `${K}.${NestedKeyOf<T[K]>}` : K
}[keyof T & string]

interface DeepObj {
  user: {
    profile: {
      name: string
      avatar: string
    }
    settings: {
      theme: string
    }
  }
}

type Paths = NestedKeyOf<DeepObj>
// 'user' | 'user.profile' | 'user.profile.name' | 'user.profile.avatar' | 'user.settings' | 'user.settings.theme'

패턴 9: HKT (Higher-Kinded Types) 에뮬레이션

TypeScript는 고차 카인드 타입을 직접 지원하지 않지만, 에뮬레이션할 수 있습니다.

// HKT 인터페이스
interface HKT {
  readonly type: unknown
}

// Functor 인터페이스 에뮬레이션
interface Functor<F extends HKT> {
  map<A, B>(fa: Kind<F, A>, f: (a: A) => B): Kind<F, B>
}

// Kind 타입 헬퍼
type Kind<F extends HKT, A> = F extends { readonly type: unknown }
  ? (F & { readonly type: A })['type']
  : never

// Array Functor
interface ArrayHKT extends HKT {
  readonly type: Array<this['type']>
}

// Option Functor
type Option<A> = { tag: 'some'; value: A } | { tag: 'none' }

interface OptionHKT extends HKT {
  readonly type: Option<this['type']>
}

패턴 10: Module Augmentation과 Declaration Merging

// 기존 모듈 확장
declare module 'express' {
  interface Request {
    user?: {
      id: string
      role: string
    }
    requestId: string
  }
}

// 인터페이스 합치기 (Declaration Merging)
interface Window {
  __APP_CONFIG__: {
    apiUrl: string
    version: string
  }
}

// 기존 enum 확장
enum Color {
  Red = 'RED',
  Blue = 'BLUE',
}

// 네임스페이스를 통한 확장
namespace Color {
  export function fromHex(hex: string): Color {
    // 구현
    return Color.Red
  }
}

Color.fromHex('#ff0000') // OK

7. Zod + tRPC: 런타임 + 컴파일타임 타입 안전

Zod 스키마로 런타임 검증 + 타입 추론

Zod는 "스키마 우선(schema-first)" 접근 방식으로, 하나의 스키마에서 런타임 검증과 TypeScript 타입을 모두 얻을 수 있습니다.

import { z } from 'zod'

// 스키마 정의 = 타입 정의 + 런타임 검증
const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(2).max(50),
  email: z.string().email(),
  age: z.number().int().min(0).max(150),
  role: z.enum(['admin', 'user', 'moderator']),
  preferences: z.object({
    theme: z.enum(['light', 'dark']).default('light'),
    notifications: z.boolean().default(true),
    language: z.string().optional(),
  }),
  createdAt: z.coerce.date(),
})

// 타입 자동 추론
type User = z.infer<typeof UserSchema>
// {
//   id: string;
//   name: string;
//   email: string;
//   age: number;
//   role: 'admin' | 'user' | 'moderator';
//   preferences: {
//     theme: 'light' | 'dark';
//     notifications: boolean;
//     language?: string;
//   };
//   createdAt: Date;
// }

// 런타임 검증
const result = UserSchema.safeParse(unknownData)
if (result.success) {
  console.log(result.data.name) // 타입 안전
} else {
  console.error(result.error.issues) // 상세한 에러 정보
}

Zod 고급 패턴

// Discriminated unions
const EventSchema = z.discriminatedUnion('type', [
  z.object({
    type: z.literal('click'),
    x: z.number(),
    y: z.number(),
  }),
  z.object({
    type: z.literal('keypress'),
    key: z.string(),
    modifiers: z.array(z.string()),
  }),
  z.object({
    type: z.literal('scroll'),
    deltaX: z.number(),
    deltaY: z.number(),
  }),
])

type AppEvent = z.infer<typeof EventSchema>

// transform으로 타입 변환
const DateFromString = z.string().transform((str) => new Date(str))
type ParsedDate = z.infer<typeof DateFromString> // Date

// 재귀 스키마
type Category = z.infer<typeof CategorySchema>
const CategorySchema: z.ZodType<Category> = z.lazy(() =>
  z.object({
    name: z.string(),
    subcategories: z.array(CategorySchema),
  })
)

tRPC: 풀스택 타입 안전 API

tRPC를 사용하면 별도의 코드 생성 없이 서버-클라이언트 간 완전한 타입 안전성을 확보할 수 있습니다.

// 서버 라우터 정의
import { initTRPC } from '@trpc/server'

const t = initTRPC.create()

const appRouter = t.router({
  user: t.router({
    getById: t.procedure.input(z.object({ id: z.string().uuid() })).query(async ({ input }) => {
      // input은 자동으로 { id: string } 타입
      const user = await db.user.findUnique({ where: { id: input.id } })
      return user
    }),

    create: t.procedure
      .input(
        z.object({
          name: z.string().min(2),
          email: z.string().email(),
          role: z.enum(['admin', 'user']),
        })
      )
      .mutation(async ({ input }) => {
        return await db.user.create({ data: input })
      }),

    list: t.procedure
      .input(
        z.object({
          page: z.number().int().min(1).default(1),
          limit: z.number().int().min(1).max(100).default(20),
          role: z.enum(['admin', 'user']).optional(),
        })
      )
      .query(async ({ input }) => {
        return await db.user.findMany({
          skip: (input.page - 1) * input.limit,
          take: input.limit,
          where: input.role ? { role: input.role } : undefined,
        })
      }),
  }),
})

export type AppRouter = typeof appRouter

React Query + tRPC 통합

// 클라이언트에서 — 완전한 타입 안전
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/router';

const trpc = createTRPCReact<AppRouter>();

function UserProfile({ userId }: { userId: string }) {
  // 자동 완성 + 타입 안전
  const userQuery = trpc.user.getById.useQuery({ id: userId });

  if (userQuery.isLoading) return <div>로딩 중...</div>;
  if (userQuery.error) return <div>에러 발생</div>;

  // userQuery.data의 타입이 서버에서 자동 추론됨
  return <div>{userQuery.data.name}</div>;
}

function CreateUserForm() {
  const createUser = trpc.user.create.useMutation({
    onSuccess: (data) => {
      // data 타입이 서버 반환 타입과 일치
      console.log(`사용자 생성 완료: ${data.name}`);
    },
  });

  const handleSubmit = (formData: FormData) => {
    createUser.mutate({
      name: formData.get('name') as string,
      email: formData.get('email') as string,
      role: 'user',
    });
  };

  return <form onSubmit={(e) => { e.preventDefault(); handleSubmit(new FormData(e.currentTarget)); }}>...</form>;
}

End-to-end Type Safety 아키텍처

// 전체 아키텍처 흐름
// 1. Zod 스키마 정의 (단일 진실 공급원)
// 2. tRPC 라우터에서 스키마 사용 (서버)
// 3. 클라이언트에서 타입 자동 추론 (프론트엔드)
// 4. 런타임 검증은 Zod가 자동 처리

// DB 스키마와의 연동 (Drizzle ORM 예시)
import { pgTable, varchar, integer, timestamp } from 'drizzle-orm/pg-core'

// DB 테이블 정의
const users = pgTable('users', {
  id: varchar('id', { length: 36 }).primaryKey(),
  name: varchar('name', { length: 50 }).notNull(),
  email: varchar('email', { length: 255 }).notNull().unique(),
  age: integer('age'),
  createdAt: timestamp('created_at').defaultNow(),
})

// Drizzle에서 타입 추론
type DbUser = typeof users.$inferSelect
type NewDbUser = typeof users.$inferInsert

// Zod 스키마와 DB 타입 동기화
const CreateUserInput = z.object({
  name: z.string().min(2).max(50),
  email: z.string().email().max(255),
  age: z.number().int().min(0).optional(),
}) satisfies z.ZodType<Omit<NewDbUser, 'id' | 'createdAt'>>

8. TypeScript 성능 최적화

tsconfig.json 최적 설정

{
  "compilerOptions": {
    "target": "es2022",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "strict": true,
    "skipLibCheck": true,
    "incremental": true,
    "tsBuildInfoFile": "./.tsbuildinfo",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "forceConsistentCasingInFileNames": true
  }
}

Type Instantiation 깊이 제한

복잡한 재귀 타입은 TypeScript의 타입 인스턴스화 깊이 제한(기본 50)에 걸릴 수 있습니다.

// 나쁜 예: 무한 재귀 위험
type DeepNested<T, Depth extends number[] = []> = Depth['length'] extends 10
  ? T
  : { value: T; nested: DeepNested<T, [...Depth, 0]> }

// 좋은 예: 재귀 깊이를 제한
type MaxDepth = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

type SafeDeepPartial<T, D extends number = 5> = [D] extends [never]
  ? T
  : T extends object
    ? { [K in keyof T]?: SafeDeepPartial<T[K], MaxDepth[D]> }
    : T

Project References로 빌드 속도 개선

대규모 모노레포에서 Project References를 사용하면 변경된 프로젝트만 재빌드됩니다.

{
  "references": [
    { "path": "./packages/shared" },
    { "path": "./packages/api" },
    { "path": "./packages/web" }
  ],
  "compilerOptions": {
    "composite": true,
    "incremental": true
  }
}

각 패키지의 tsconfig.json:

{
  "compilerOptions": {
    "composite": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "declarationMap": true
  },
  "references": [{ "path": "../shared" }]
}

빌드 명령:

# 프로젝트 참조를 따라 빌드
tsc --build

# 변경된 것만 증분 빌드
tsc --build --incremental

# 클린 빌드
tsc --build --clean

SWC vs esbuild 트랜스파일러 비교

항목tscSWCesbuild
언어TypeScriptRustGo
타입 체크있음없음없음
속도 (1000 파일)~10초~0.5초~0.3초
번들링없음있음 (SWC Pack)있음
데코레이터전체 지원부분 지원제한적
추천 사용CI 타입 체크Next.js, JestVite, 번들링
{
  "scripts": {
    "typecheck": "tsc --noEmit",
    "build": "esbuild src/index.ts --bundle --outdir=dist",
    "dev": "tsx watch src/index.ts"
  }
}

9. 타입 체조 (Type Challenges) 실전

타입 체조는 TypeScript의 타입 시스템만으로 로직을 구현하는 연습입니다. 실전에서 복잡한 타입을 다룰 때 큰 도움이 됩니다.

Easy 레벨

// 1. MyPick 구현
type MyPick<T, K extends keyof T> = {
  [P in K]: T[P]
}

// 테스트
interface Todo {
  title: string
  description: string
  completed: boolean
}

type TodoPreview = MyPick<Todo, 'title' | 'completed'>
// { title: string; completed: boolean }

// 2. MyReadonly 구현
type MyReadonly<T> = {
  readonly [K in keyof T]: T[K]
}

// 3. Tuple to Object
type TupleToObject<T extends readonly (string | number | symbol)[]> = {
  [K in T[number]]: K
}

const tuple = ['tesla', 'model 3', 'model X'] as const
type TupleObj = TupleToObject<typeof tuple>
// { tesla: 'tesla'; 'model 3': 'model 3'; 'model X': 'model X' }

// 4. First of Array
type First<T extends any[]> = T extends [infer F, ...any[]] ? F : never

type A = First<[3, 2, 1]> // 3
type B = First<[]> // never

// 5. Length of Tuple
type Length<T extends readonly any[]> = T['length']

type L = Length<[1, 2, 3]> // 3

Medium 레벨

// 1. Deep Readonly
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object
    ? T[K] extends Function
      ? T[K]
      : DeepReadonly<T[K]>
    : T[K]
}

// 2. Chainable Options
type Chainable<T = {}> = {
  option<K extends string, V>(
    key: K extends keyof T ? never : K,
    value: V
  ): Chainable<T & Record<K, V>>
  get(): T
}

declare const config: Chainable
const result = config
  .option('foo', 123)
  .option('name', 'hello')
  .option('bar', { value: 'world' })
  .get()
// { foo: number; name: string; bar: { value: string } }

// 3. Trim
type TrimLeft<S extends string> = S extends `${' ' | '\n' | '\t'}${infer R}` ? TrimLeft<R> : S

type TrimRight<S extends string> = S extends `${infer R}${' ' | '\n' | '\t'}` ? TrimRight<R> : S

type Trim<S extends string> = TrimLeft<TrimRight<S>>

type Trimmed = Trim<'  Hello World  '> // 'Hello World'

// 4. Replace
type Replace<S extends string, From extends string, To extends string> = From extends ''
  ? S
  : S extends `${infer L}${From}${infer R}`
    ? `${L}${To}${R}`
    : S

type Replaced = Replace<'hello world', 'world', 'TypeScript'>
// 'hello TypeScript'

// 5. Flatten
type Flatten<T extends any[]> = T extends [infer First, ...infer Rest]
  ? First extends any[]
    ? [...Flatten<First>, ...Flatten<Rest>]
    : [First, ...Flatten<Rest>]
  : []

type Flat = Flatten<[1, [2, [3, [4]]]]> // [1, 2, 3, 4]

Hard 레벨

// 1. Curry (간소화 버전)
type Curry<F> = F extends (...args: infer A) => infer R
  ? A extends [infer First, ...infer Rest]
    ? (arg: First) => Rest extends [] ? R : Curry<(...args: Rest) => R>
    : R
  : never

declare function curry<F extends (...args: any[]) => any>(fn: F): Curry<F>

function add(a: number, b: number, c: number): number {
  return a + b + c
}

const curriedAdd = curry(add)
// (arg: number) => (arg: number) => (arg: number) => number
const result2 = curriedAdd(1)(2)(3) // number

// 2. String Parser (간소화된 경로 파서)
type ParsePath<S extends string> = S extends `${string}:${infer Param}/${infer Rest}`
  ? { [K in Param]: string } & ParsePath<Rest>
  : S extends `${string}:${infer Param}`
    ? { [K in Param]: string }
    : {}

type Params = ParsePath<'/users/:userId/posts/:postId'>
// { userId: string } & { postId: string }

// 3. IsUnion
type IsUnion<T, U = T> = T extends U ? ([U] extends [T] ? false : true) : never

type A1 = IsUnion<string | number> // true
type A2 = IsUnion<string> // false

학습 로드맵과 추천 자료

초급 (1-2주)

  1. 기본 타입, 인터페이스, 제네릭 기초
  2. Union Types, Intersection Types
  3. Type Guards, Narrowing

중급 (2-4주)

  1. Conditional Types, Mapped Types
  2. Template Literal Types
  3. 유틸리티 타입 내부 구현 이해

고급 (4-8주)

  1. 재귀 타입, Variadic Tuple Types
  2. Branded Types, Phantom Types
  3. HKT 에뮬레이션
  4. type-challenges 리포지토리 풀이

마스터 (계속)

  1. 컴파일러 API 활용
  2. 커스텀 트랜스포머 작성
  3. Language Service Plugin 개발

10. 퀴즈

Q1: NoInfer 유틸리티 타입의 주요 목적은 무엇인가요?

NoInfer는 제네릭 함수에서 특정 매개변수가 타입 추론에 참여하지 않도록 제어하는 유틸리티 타입입니다. 이를 통해 의도하지 않은 타입 확장(widening)을 방지할 수 있습니다. 예를 들어 기본값 매개변수가 제네릭 타입 T의 추론에 영향을 주지 않도록 할 때 사용합니다.

Q2: Distributive Conditional Types에서 분배를 방지하려면 어떻게 해야 하나요?

Conditional Types에서 유니온 분배를 방지하려면 extends 양쪽을 대괄호로 감쌉니다. 예를 들어 T extends any ? T[] : never 대신 [T] extends [any] ? T[] : never로 작성하면 유니온 타입이 개별적으로 분배되지 않고 전체로 처리됩니다.

Q3: Branded Types이 필요한 이유는 무엇인가요?

TypeScript의 구조적 타이핑 시스템에서는 구조가 같은 타입이 호환됩니다. 예를 들어 UserId와 PostId가 모두 string이면 서로 바꿔 넣어도 컴파일 에러가 발생하지 않습니다. Branded Types은 고유한 브랜드 속성을 추가하여 구조적으로는 같지만 논리적으로 다른 타입을 구분할 수 있게 해줍니다. 이로써 잘못된 ID 사용을 컴파일 타임에 방지할 수 있습니다.

Q4: tRPC가 기존 REST API 대비 제공하는 타입 안전 이점은 무엇인가요?

tRPC는 서버에서 정의한 라우터 타입이 클라이언트에서 자동으로 추론됩니다. 별도의 코드 생성(codegen) 과정 없이, 서버의 입력/출력 타입이 클라이언트에서 완전한 자동 완성과 타입 체크를 제공합니다. Zod 스키마와 결합하면 런타임 검증까지 자동으로 이루어져 End-to-end 타입 안전성을 확보할 수 있습니다.

Q5: TypeScript Project References의 주요 장점은 무엇인가요?

Project References는 대규모 모노레포에서 빌드 성능을 최적화합니다. 주요 장점은 다음과 같습니다. 첫째, 변경된 프로젝트만 재빌드하여 증분 빌드가 가능합니다. 둘째, 프로젝트 간 의존 관계를 명시적으로 선언하여 빌드 순서를 자동 관리합니다. 셋째, composite 옵션과 함께 사용하면 각 프로젝트의 선언 파일을 캐시하여 다른 프로젝트에서 참조할 때 소스 파일을 다시 파싱하지 않습니다.


11. 참고 자료

  1. TypeScript 공식 문서 — Handbook
  2. TypeScript 5.4 릴리스 노트
  3. TypeScript 5.5 릴리스 노트
  4. TypeScript 5.6 릴리스 노트
  5. TypeScript 5.7 릴리스 노트
  6. type-challenges GitHub
  7. Zod 공식 문서
  8. tRPC 공식 문서
  9. Total TypeScript — Matt Pocock
  10. TypeScript Deep Dive — Basarat
  11. Effect-TS — 타입 안전 함수형 프로그래밍
  12. Drizzle ORM — 타입 안전 ORM
  13. SWC 공식 문서
  14. esbuild 공식 문서
  15. TypeScript Performance Wiki
  16. Branded Types in TypeScript — Kent C. Dodds
  17. TypeScript 타입 시스템이 튜링 완전한 이유