Skip to content
Published on

TypeScript 완전 정복: 타입 시스템부터 고급 패턴까지 실전 가이드

Authors

TypeScript 완전 정복: 타입 시스템부터 고급 패턴까지 실전 가이드

TypeScript는 JavaScript에 정적 타입 시스템을 추가한 언어입니다. 2026년 현재 프론트엔드와 백엔드를 막론하고 대부분의 대규모 프로젝트에서 TypeScript를 표준으로 채택하고 있습니다. 이 가이드에서는 기초부터 고급 패턴까지 실무에서 바로 활용할 수 있는 내용을 다룹니다.


1. TypeScript 타입 시스템 기초

1.1 원시 타입 (Primitive Types)

TypeScript의 원시 타입은 JavaScript의 것과 동일하지만 명시적으로 선언합니다.

// 기본 원시 타입
const name: string = '홍길동'
const age: number = 30
const isActive: boolean = true
const nothing: null = null
const undef: undefined = undefined

// ES6+ 타입
const sym: symbol = Symbol('unique')
const big: bigint = 9007199254740991n

// 타입 추론 - 대입 시 TypeScript가 자동으로 타입을 추론
const inferredName = '홍길동' // string으로 추론
const inferredAge = 30 // number로 추론

1.2 배열과 튜플

// 배열 타입 표기 방법 (두 가지 동일)
const nums1: number[] = [1, 2, 3]
const nums2: Array<number> = [1, 2, 3]

// 읽기 전용 배열
const readonlyNums: readonly number[] = [1, 2, 3]
// readonlyNums.push(4)  // Error: push does not exist on readonly

// 튜플 - 길이와 각 위치의 타입이 고정
const pair: [string, number] = ['Alice', 30]
const triple: [string, number, boolean] = ['Bob', 25, true]

// 선택적 튜플 요소
const optional: [string, number?] = ['Charlie']

// 나머지 튜플
const rest: [string, ...number[]] = ['start', 1, 2, 3, 4]

1.3 Enum

// 숫자 열거형 (기본: 0부터 시작)
enum Direction {
  Up, // 0
  Down, // 1
  Left, // 2
  Right, // 3
}

// 문자열 열거형
enum Color {
  Red = 'RED',
  Green = 'GREEN',
  Blue = 'BLUE',
}

// const enum - 컴파일 시 인라인으로 대체되어 번들 크기 최적화
const enum Status {
  Active = 'ACTIVE',
  Inactive = 'INACTIVE',
  Pending = 'PENDING',
}

// const enum 사용 시 컴파일 결과: 'ACTIVE' 문자열로 직접 치환
const currentStatus = Status.Active // 컴파일 후: 'ACTIVE'

// 일반 enum vs const enum 선택 기준:
// - 런타임에 enum 객체를 순회할 필요가 있다면 일반 enum
// - 성능 최적화가 중요하고 객체 순회가 불필요하면 const enum

1.4 any vs unknown vs never vs void

이 네 가지는 TypeScript에서 가장 혼동하기 쉬운 특수 타입입니다.

// any - 타입 검사를 완전히 비활성화 (사용 자제 권장)
let anyVal: any = '문자열'
anyVal = 42 // OK
anyVal = true // OK
anyVal.nonExistent() // 런타임 오류 가능, 컴파일 오류 없음

// unknown - any보다 안전한 대안
let unknownVal: unknown = '문자열'
// unknownVal.toUpperCase()  // Error: 타입 검사 필요
if (typeof unknownVal === 'string') {
  unknownVal.toUpperCase() // OK: 타입 가드 후 사용 가능
}

// never - 절대 발생하지 않는 값의 타입
function throwError(msg: string): never {
  throw new Error(msg)
}

function infiniteLoop(): never {
  while (true) {}
}

// never는 완전성 검사에 유용
type Shape = 'circle' | 'square' | 'triangle'
function area(shape: Shape): number {
  switch (shape) {
    case 'circle':
      return Math.PI * 1
    case 'square':
      return 1
    case 'triangle':
      return 0.5
    default:
      // shape가 never 타입이어야 함 - 새로운 Shape 추가 시 컴파일 오류 발생
      const _exhaustive: never = shape
      throw new Error(`Unknown shape: ${_exhaustive}`)
  }
}

// void - 반환값이 없는 함수
function logMessage(msg: string): void {
  console.log(msg)
  // return undefined  // OK
  // return 'value'    // Error
}

1.5 Type Alias vs Interface

// Type Alias
type Point = {
  x: number
  y: number
}

type ID = string | number

// Interface
interface IPoint {
  x: number
  y: number
}

// 주요 차이점:
// 1. Interface는 선언 병합(Declaration Merging) 가능
interface IPoint {
  z?: number // 기존 IPoint에 z 추가됨
}

// 2. Type Alias는 Union/Intersection/Primitive 등 모든 타입에 사용 가능
type StringOrNumber = string | number
type WithTimestamp = Point & { createdAt: Date }

// 3. Interface는 class가 implements 가능 (Type도 가능)
class MyPoint implements IPoint {
  x = 0
  y = 0
}

// 선택 기준:
// - 라이브러리/SDK 공개 API: Interface (선언 병합으로 확장 용이)
// - 앱 내부 타입: Type Alias (더 유연)
// - 객체 형태만 표현: 둘 다 가능

1.6 Literal Types & Template Literal Types

// 리터럴 타입
type Direction = 'north' | 'south' | 'east' | 'west'
type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6

const move = (dir: Direction) => console.log(dir)
move('north') // OK
// move('up')  // Error

// 템플릿 리터럴 타입 (TypeScript 4.1+)
type EventName = 'click' | 'focus' | 'blur'
type Handler = `on${Capitalize<EventName>}`
// 결과: 'onClick' | 'onFocus' | 'onBlur'

type CSSUnit = 'px' | 'em' | 'rem' | 'vw' | 'vh'
type CSSValue = `${number}${CSSUnit}`
// 예: '16px', '1.5em', '100vw' 등

// 실용 예시: API 엔드포인트 타입
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
type Endpoint = '/users' | '/posts' | '/comments'
type APIRoute = `${HttpMethod} ${Endpoint}`
// 결과: 'GET /users' | 'GET /posts' | ... 등 12가지 조합

2. 객체 타입과 유틸리티 타입

2.1 객체 타입 고급 기능

interface User {
  readonly id: number // 읽기 전용
  name: string
  email?: string // 선택적 속성
  [key: string]: unknown // 인덱스 시그니처
}

const user: User = { id: 1, name: 'Alice' }
// user.id = 2  // Error: readonly

// 중첩 readonly
interface DeepReadonly {
  readonly settings: {
    readonly theme: string
    readonly language: string
  }
}

2.2 내장 유틸리티 타입

interface Todo {
  id: number
  title: string
  completed: boolean
  description: string
}

// Partial<T> - 모든 속성을 선택적으로
type PartialTodo = Partial<Todo>
// { id?: number; title?: string; completed?: boolean; description?: string }

// Required<T> - 모든 속성을 필수로
type RequiredTodo = Required<Partial<Todo>>

// Readonly<T> - 모든 속성을 읽기 전용으로
type ReadonlyTodo = Readonly<Todo>

// Pick<T, K> - 특정 속성만 선택
type TodoPreview = Pick<Todo, 'id' | 'title'>

// Omit<T, K> - 특정 속성 제외
type TodoWithoutDescription = Omit<Todo, 'description'>

// Record<K, V> - 키-값 맵 타입
type TodoMap = Record<number, Todo>
const todos: TodoMap = {
  1: { id: 1, title: 'Learn TypeScript', completed: false, description: '' },
}

// Exclude<T, U> - Union에서 U에 해당하는 타입 제거
type T1 = Exclude<string | number | boolean, boolean> // string | number

// Extract<T, U> - Union에서 U에 해당하는 타입만 추출
type T2 = Extract<string | number | boolean, string | boolean> // string | boolean

// NonNullable<T> - null과 undefined 제거
type T3 = NonNullable<string | null | undefined> // string

// ReturnType<T> - 함수 반환 타입 추출
function getUser() {
  return { id: 1, name: 'Alice' }
}
type UserReturn = ReturnType<typeof getUser> // { id: number; name: string }

// Parameters<T> - 함수 매개변수 타입 추출 (튜플)
function createUser(name: string, age: number, active: boolean) {}
type CreateUserParams = Parameters<typeof createUser> // [string, number, boolean]

// ConstructorParameters<T> - 생성자 매개변수 타입 추출
class ApiClient {
  constructor(
    public baseUrl: string,
    public timeout: number
  ) {}
}
type ClientParams = ConstructorParameters<typeof ApiClient> // [string, number]

2.3 Mapped Types

// 기본 Mapped Type
type Flags<T> = {
  [K in keyof T]: boolean
}

interface Config {
  darkMode: string
  notifications: string
  analytics: string
}

type ConfigFlags = Flags<Config>
// { darkMode: boolean; notifications: boolean; analytics: boolean }

// 수정자 추가/제거
type Mutable<T> = {
  -readonly [K in keyof T]: T[K] // readonly 제거
}

type AllRequired<T> = {
  [K in keyof T]-?: T[K] // ? 선택성 제거
}

// 키 재매핑 (TypeScript 4.1+)
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}

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

// 필터링을 위한 키 재매핑
type OnlyStrings<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K]
}

2.4 Conditional Types

// 기본 조건부 타입
type IsString<T> = T extends string ? true : false
type A = IsString<string> // true
type B = IsString<number> // false

// Distributive Conditional Types (Union에 분배)
type ToArray<T> = T extends any ? T[] : never
type C = ToArray<string | number> // string[] | number[]

// infer 키워드 - 조건부 타입 안에서 타입 추론
type UnpackArray<T> = T extends Array<infer Item> ? Item : T
type D = UnpackArray<string[]> // string
type E = UnpackArray<number> // number (배열이 아니므로 그대로)

type UnpackPromise<T> = T extends Promise<infer R> ? R : T
type F = UnpackPromise<Promise<string>> // string

// 함수 반환 타입 직접 구현 (ReturnType의 원리)
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never
type G = MyReturnType<() => string> // string

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

3. 제네릭 (Generics)

3.1 제네릭 함수

// 기본 제네릭 함수
function identity<T>(value: T): T {
  return value
}

const str = identity<string>('hello') // string
const num = identity(42) // number (타입 추론)

// 여러 타입 파라미터
function pair<A, B>(first: A, second: B): [A, B] {
  return [first, second]
}

// 배열의 첫 번째 요소 반환
function first<T>(arr: T[]): T | undefined {
  return arr[0]
}

// 제네릭 화살표 함수 (TSX 파일에서는 주의 필요)
const wrap = <T>(value: T): { value: T } => ({ value })

3.2 제네릭 인터페이스와 클래스

// 제네릭 인터페이스
interface Repository<T> {
  findById(id: number): Promise<T | null>
  findAll(): Promise<T[]>
  create(item: Omit<T, 'id'>): Promise<T>
  update(id: number, item: Partial<T>): Promise<T>
  delete(id: number): Promise<void>
}

// 제네릭 클래스
class Stack<T> {
  private items: T[] = []

  push(item: T): void {
    this.items.push(item)
  }

  pop(): T | undefined {
    return this.items.pop()
  }

  peek(): T | undefined {
    return this.items[this.items.length - 1]
  }

  get size(): number {
    return this.items.length
  }

  isEmpty(): boolean {
    return this.items.length === 0
  }
}

const stack = new Stack<number>()
stack.push(1)
stack.push(2)
console.log(stack.pop()) // 2

3.3 제네릭 제약 (extends)

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

const user = { name: 'Alice', age: 30 }
const name = getProperty(user, 'name') // string
// getProperty(user, 'email')  // Error: 'email'은 keyof typeof user가 아님

// 인터페이스 제약
interface HasId {
  id: number
}

function findById<T extends HasId>(items: T[], id: number): T | undefined {
  return items.find((item) => item.id === id)
}

// 다중 제약
interface Serializable {
  serialize(): string
}

function processAndSerialize<T extends HasId & Serializable>(item: T): string {
  return `${item.id}: ${item.serialize()}`
}

3.4 실용적인 제네릭 패턴

// API Response 타입 패턴
interface ApiResponse<T> {
  data: T
  status: number
  message: string
  timestamp: string
}

interface PaginatedResponse<T> extends ApiResponse<T[]> {
  pagination: {
    page: number
    limit: number
    total: number
    totalPages: number
  }
}

async function fetchUsers(): Promise<ApiResponse<User[]>> {
  const response = await fetch('/api/users')
  return response.json()
}

// Repository 패턴
class UserRepository implements Repository<User> {
  private baseUrl = '/api/users'

  async findById(id: number): Promise<User | null> {
    const res = await fetch(`${this.baseUrl}/${id}`)
    if (!res.ok) return null
    return res.json()
  }

  async findAll(): Promise<User[]> {
    const res = await fetch(this.baseUrl)
    return res.json()
  }

  async create(item: Omit<User, 'id'>): Promise<User> {
    const res = await fetch(this.baseUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(item),
    })
    return res.json()
  }

  async update(id: number, item: Partial<User>): Promise<User> {
    const res = await fetch(`${this.baseUrl}/${id}`, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(item),
    })
    return res.json()
  }

  async delete(id: number): Promise<void> {
    await fetch(`${this.baseUrl}/${id}`, { method: 'DELETE' })
  }
}

4. 고급 타입 기법

4.1 Union Types와 Intersection Types

// Union Types
type StringOrNumber = string | number
type Nullable<T> = T | null
type Optional<T> = T | null | undefined

// Intersection Types
interface Named {
  name: string
}

interface Aged {
  age: number
}

type Person = Named & Aged
const person: Person = { name: 'Alice', age: 30 }

// 유용한 intersection 패턴
type WithTimestamp<T> = T & {
  createdAt: Date
  updatedAt: Date
}

type UserWithTimestamp = WithTimestamp<User>

4.2 Discriminated Unions (Tagged Union)

// 각 케이스에 고유한 리터럴 타입 판별자(discriminant) 사용
interface LoadingState {
  status: 'loading'
}

interface SuccessState<T> {
  status: 'success'
  data: T
}

interface ErrorState {
  status: 'error'
  error: Error
}

type AsyncState<T> = LoadingState | SuccessState<T> | ErrorState

// 판별자를 이용한 타입 좁히기
function renderState<T>(state: AsyncState<T>): string {
  switch (state.status) {
    case 'loading':
      return '로딩 중...'
    case 'success':
      return `데이터: ${JSON.stringify(state.data)}`
    case 'error':
      return `오류: ${state.error.message}`
    // default: TypeScript가 모든 케이스를 처리했음을 알고 있음
  }
}

// Redux 액션 패턴
type Action =
  | { type: 'INCREMENT'; payload: number }
  | { type: 'DECREMENT'; payload: number }
  | { type: 'RESET' }

function reducer(state: number, action: Action): number {
  switch (action.type) {
    case 'INCREMENT':
      return state + action.payload
    case 'DECREMENT':
      return state - action.payload
    case 'RESET':
      return 0
  }
}

4.3 Type Guards

// typeof 타입 가드
function processValue(value: string | number): string {
  if (typeof value === 'string') {
    return value.toUpperCase() // value: string
  }
  return value.toFixed(2) // value: number
}

// instanceof 타입 가드
class Cat {
  meow() {
    return '야옹'
  }
}

class Dog {
  bark() {
    return '멍멍'
  }
}

function speak(animal: Cat | Dog): string {
  if (animal instanceof Cat) {
    return animal.meow() // animal: Cat
  }
  return animal.bark() // animal: Dog
}

// in 연산자 타입 가드
interface Fish {
  swim(): void
}

interface Bird {
  fly(): void
}

function move(animal: Fish | Bird): void {
  if ('swim' in animal) {
    animal.swim() // animal: Fish
  } else {
    animal.fly() // animal: Bird
  }
}

// 사용자 정의 타입 가드 (is 키워드)
function isString(value: unknown): value is string {
  return typeof value === 'string'
}

function isUser(obj: unknown): obj is User {
  return typeof obj === 'object' && obj !== null && 'id' in obj && 'name' in obj
}

// Assertion Function (TypeScript 3.7+)
function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== 'string') {
    throw new Error(`Expected string, got ${typeof value}`)
  }
}

const maybeString: unknown = 'hello'
assertIsString(maybeString)
console.log(maybeString.toUpperCase()) // 이후 string으로 확정

4.4 Overload Signatures

// 오버로드 시그니처
function process(x: number): number
function process(x: string): string
function process(x: number[]): number[]
function process(x: number | string | number[]): number | string | number[] {
  if (typeof x === 'number') return x * 2
  if (typeof x === 'string') return x.toUpperCase()
  return x.map((n) => n * 2)
}

const r1 = process(10) // number
const r2 = process('hello') // string
const r3 = process([1, 2, 3]) // number[]

// 메서드 오버로드
class EventEmitter {
  on(event: 'data', listener: (data: Buffer) => void): this
  on(event: 'end', listener: () => void): this
  on(event: 'error', listener: (err: Error) => void): this
  on(event: string, listener: (...args: any[]) => void): this {
    // 구현
    return this
  }
}

5. 클래스와 데코레이터

5.1 접근 제한자와 클래스 기능

class BankAccount {
  readonly accountNumber: string
  private _balance: number
  protected owner: string

  constructor(accountNumber: string, owner: string, initialBalance = 0) {
    this.accountNumber = accountNumber
    this.owner = owner
    this._balance = initialBalance
  }

  // getter/setter
  get balance(): number {
    return this._balance
  }

  // private 메서드
  private validateAmount(amount: number): void {
    if (amount <= 0) throw new Error('금액은 0보다 커야 합니다')
  }

  deposit(amount: number): void {
    this.validateAmount(amount)
    this._balance += amount
  }

  withdraw(amount: number): void {
    this.validateAmount(amount)
    if (amount > this._balance) throw new Error('잔액 부족')
    this._balance -= amount
  }

  // 정적 메서드
  static createSavingsAccount(owner: string): BankAccount {
    const accountNumber = `SAV-${Date.now()}`
    return new BankAccount(accountNumber, owner, 0)
  }
}

// 생성자 매개변수 단축 (Parameter Properties)
class Point {
  constructor(
    public readonly x: number,
    public readonly y: number
  ) {}

  distance(other: Point): number {
    return Math.sqrt((this.x - other.x) ** 2 + (this.y - other.y) ** 2)
  }
}

5.2 Abstract Class

abstract class Shape {
  abstract area(): number
  abstract perimeter(): number

  // 공통 구현
  toString(): string {
    return `넓이: ${this.area().toFixed(2)}, 둘레: ${this.perimeter().toFixed(2)}`
  }
}

class Circle extends Shape {
  constructor(private radius: number) {
    super()
  }

  area(): number {
    return Math.PI * this.radius ** 2
  }

  perimeter(): number {
    return 2 * Math.PI * this.radius
  }
}

class Rectangle extends Shape {
  constructor(
    private width: number,
    private height: number
  ) {
    super()
  }

  area(): number {
    return this.width * this.height
  }

  perimeter(): number {
    return 2 * (this.width + this.height)
  }
}

5.3 Decorators

데코레이터는 tsconfig.json에서 experimentalDecorators: true를 설정해야 합니다.

// 클래스 데코레이터
function Singleton<T extends new (...args: any[]) => {}>(constructor: T) {
  let instance: InstanceType<T>
  return class extends constructor {
    constructor(...args: any[]) {
      if (instance) return instance
      super(...args)
      instance = this as any
    }
  }
}

@Singleton
class AppConfig {
  readonly version = '1.0.0'
}

// 메서드 데코레이터
function Log(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value
  descriptor.value = function (...args: any[]) {
    console.log(`호출: ${propertyKey}(${JSON.stringify(args)})`)
    const result = original.apply(this, args)
    console.log(`반환: ${JSON.stringify(result)}`)
    return result
  }
  return descriptor
}

// 속성 데코레이터
function Validate(min: number, max: number) {
  return function (target: any, propertyKey: string) {
    let value: number
    Object.defineProperty(target, propertyKey, {
      get: () => value,
      set: (newValue: number) => {
        if (newValue < min || newValue > max) {
          throw new Error(`${propertyKey}${min}-${max} 범위여야 합니다`)
        }
        value = newValue
      },
    })
  }
}

class Product {
  name: string

  @Validate(0, 1000000)
  price: number

  constructor(name: string, price: number) {
    this.name = name
    this.price = price
  }

  @Log
  getInfo(): string {
    return `${this.name}: ${this.price}`
  }
}

6. 모듈 시스템과 Declaration Files

6.1 ES Modules in TypeScript

// named export
export interface User {
  id: number
  name: string
}

export function createUser(name: string): User {
  return { id: Date.now(), name }
}

// default export
export default class UserService {
  private users: User[] = []

  add(user: User): void {
    this.users.push(user)
  }
}

// re-export
export { User as UserType } from './types'
export * from './utils'
export * as helpers from './helpers'

// import
import UserService, { User, createUser } from './user'
import type { User as UserType } from './user' // 타입만 import (런타임 제거)

6.2 Declaration Files (.d.ts)

// globals.d.ts - 전역 타입 선언
declare global {
  interface Window {
    analytics: {
      track(event: string, properties?: Record<string, unknown>): void
    }
  }
}

// 모듈 선언 (타입 없는 JS 라이브러리 보완)
declare module 'legacy-lib' {
  export function doSomething(value: string): number
  export const VERSION: string
}

// 파일 타입 선언 (예: SVG 파일)
declare module '*.svg' {
  const content: string
  export default content
}

declare module '*.png' {
  const content: string
  export default content
}

6.3 tsconfig.json 핵심 옵션

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ES2022", "DOM"],
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "paths": {
      "@/*": ["./src/*"],
      "@components/*": ["./src/components/*"]
    },
    "baseUrl": ".",
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true
  }
}

7. 실용 패턴

7.1 Builder Pattern with Method Chaining

class QueryBuilder<T> {
  private conditions: string[] = []
  private orderByClause?: string
  private limitValue?: number
  private offsetValue?: number

  where(condition: string): this {
    this.conditions.push(condition)
    return this
  }

  orderBy(field: keyof T & string, direction: 'ASC' | 'DESC' = 'ASC'): this {
    this.orderByClause = `${field} ${direction}`
    return this
  }

  limit(n: number): this {
    this.limitValue = n
    return this
  }

  offset(n: number): this {
    this.offsetValue = n
    return this
  }

  build(): string {
    let query = 'SELECT * FROM table'
    if (this.conditions.length > 0) {
      query += ` WHERE ${this.conditions.join(' AND ')}`
    }
    if (this.orderByClause) {
      query += ` ORDER BY ${this.orderByClause}`
    }
    if (this.limitValue !== undefined) {
      query += ` LIMIT ${this.limitValue}`
    }
    if (this.offsetValue !== undefined) {
      query += ` OFFSET ${this.offsetValue}`
    }
    return query
  }
}

// 사용 예시
const query = new QueryBuilder<User>()
  .where('age > 18')
  .where('active = true')
  .orderBy('name')
  .limit(10)
  .offset(0)
  .build()

7.2 Result Type (Either Monad 패턴)

type Ok<T> = { success: true; data: T }
type Err<E> = { success: false; error: E }
type Result<T, E = Error> = Ok<T> | Err<E>

function ok<T>(data: T): Ok<T> {
  return { success: true, data }
}

function err<E>(error: E): Err<E> {
  return { success: false, error }
}

// 사용 예시
async function fetchUser(id: number): Promise<Result<User>> {
  try {
    const response = await fetch(`/api/users/${id}`)
    if (!response.ok) {
      return err(new Error(`HTTP ${response.status}`))
    }
    const data = await response.json()
    return ok(data)
  } catch (e) {
    return err(e instanceof Error ? e : new Error('Unknown error'))
  }
}

// 호출부에서 명시적 에러 처리 강제
const result = await fetchUser(1)
if (result.success) {
  console.log(result.data.name)
} else {
  console.error(result.error.message)
}

7.3 Branded Types (타입 안전한 ID)

// 브랜드 타입 - 같은 기반 타입이지만 서로 호환 불가
type UserId = number & { readonly _brand: 'UserId' }
type PostId = number & { readonly _brand: 'PostId' }
type CommentId = number & { readonly _brand: 'CommentId' }

// 생성 함수 (as unknown as BrandedType 패턴)
function asUserId(id: number): UserId {
  return id as UserId
}
function asPostId(id: number): PostId {
  return id as PostId
}

function getUser(id: UserId): User {
  /* ... */ return {} as User
}
function getPost(id: PostId): void {
  /* ... */
}

const userId = asUserId(1)
const postId = asPostId(1)

getUser(userId) // OK
// getUser(postId)  // Error: PostId는 UserId에 할당 불가
// getUser(1)       // Error: number는 UserId에 할당 불가

7.4 satisfies 연산자 (TypeScript 4.9+)

// satisfies - 타입을 검증하되 추론된 타입은 유지
const palette = {
  red: [255, 0, 0],
  green: '#00ff00',
  blue: [0, 0, 255],
} satisfies Record<string, string | number[]>

// satisfies 덕분에:
palette.red.map((x) => x * 2) // OK: number[]로 추론됨
palette.green.toUpperCase() // OK: string으로 추론됨

// 타입 단언(as) 없이도 올바른 타입 유지
const config = {
  port: 3000,
  host: 'localhost',
  features: {
    auth: true,
    logging: false,
  },
} satisfies {
  port: number
  host: string
  features: Record<string, boolean>
}

8. 퀴즈

퀴즈 1: unknown vs any

다음 코드에서 컴파일 오류가 발생하는 줄을 고르시오.

const a: any = 'hello'
const b: unknown = 'world'

console.log(a.toUpperCase()) // (1)
console.log(b.toUpperCase()) // (2)

if (typeof b === 'string') {
  console.log(b.toUpperCase()) // (3)
}

정답: (2)번 줄

설명: unknown 타입은 타입 가드 없이 속성/메서드에 직접 접근할 수 없습니다. any는 타입 검사를 완전히 비활성화하므로 (1)은 통과되지만, unknown은 타입 좁히기 후에만 사용할 수 있어 (2)는 컴파일 오류입니다. (3)은 typeof 타입 가드 후이므로 OK입니다.

퀴즈 2: 유틸리티 타입 조합

다음 타입을 유틸리티 타입으로 표현하시오.

interface Article {
  id: number
  title: string
  content: string
  author: string
  publishedAt: Date
  updatedAt: Date
}

// 목표: id, publishedAt, updatedAt을 제외하고 모든 필드를 선택적으로 만든 타입
type ArticleUpdateInput = /* ??? */

정답: Partial<Omit<Article, 'id' | 'publishedAt' | 'updatedAt'>>

설명: 먼저 Omit으로 수정 불가 필드를 제거하고, Partial로 나머지 모든 필드를 선택적으로 만듭니다. 유틸리티 타입은 이처럼 조합하여 사용할 수 있습니다.

퀴즈 3: 제네릭 제약

다음 함수가 컴파일 오류 없이 동작하도록 제네릭 제약을 추가하시오.

function mergeObjects<T, U>(target: T, source: U): T & U {
  return { ...target, ...source }
}

// 요구사항: T와 U 모두 object 타입이어야 함

정답: function mergeObjects<T extends object, U extends object>(target: T, source: U): T & U

설명: extends object를 사용하면 기본 타입(string, number 등)의 전달을 방지하고 객체 타입만 허용합니다. object 대신 Record<string, unknown>을 쓰는 것이 더 명시적인 경우도 있습니다.

퀴즈 4: Discriminated Union 완전성 검사

아래 코드에서 새로운 도형 Triangle을 추가했을 때 컴파일 오류가 발생하지 않는 이유와, 오류를 발생시키려면 어떻게 수정해야 하는지 설명하시오.

type Shape = Circle | Square // Triangle 추가 후: Circle | Square | Triangle

function getArea(shape: Shape): number {
  if (shape.kind === 'circle') return Math.PI * shape.radius ** 2
  if (shape.kind === 'square') return shape.side ** 2
  return 0 // 이 줄로 인해 컴파일 오류 없음
}

정답: return 0 대신 const _: never = shape; throw new Error(...) 패턴 사용

설명: return 0은 모든 미처리 케이스를 조용히 처리합니다. never 완전성 검사 패턴을 사용하면 Triangle이 추가될 때 shapenever가 아니므로 컴파일 오류가 발생합니다.

퀴즈 5: infer와 조건부 타입

다음 타입의 결과를 예측하시오.

type Flatten<T> = T extends Array<infer Item> ? Item : T

type A = Flatten<string[]> // ?
type B = Flatten<number[][]> // ?
type C = Flatten<string> // ?
type D = Flatten<string | number[]> // ?

정답: A = string, B = number[], C = string, D = string | number

설명: Flatten은 배열의 한 단계만 풀어냅니다. Bnumber[][]Array<number[]>로 보기 때문에 number[]가 됩니다. D는 Union에 분배되어 Flatten<string> | Flatten<number[]> = string | number가 됩니다.


마치며

TypeScript의 타입 시스템은 단순한 문법 설탕이 아닙니다. 올바르게 사용하면 런타임 오류를 컴파일 타임에 잡아내고, IDE의 자동완성과 리팩토링 지원을 최대한 활용할 수 있습니다.

핵심 정리:

  • unknownany 대신 활용하여 타입 안전성 유지
  • 유틸리티 타입과 Mapped Type으로 DRY한 타입 정의
  • Discriminated Union으로 상태 머신을 안전하게 모델링
  • 제네릭으로 재사용 가능한 타입 안전 코드 작성
  • satisfies 연산자로 타입 검증과 추론을 동시에