TypeScript는 풍부한 타입 시스템을 가지고 있지만, 생산적이기 위해 모두 이해할 필요는 없습니다. 현실은 작업의 90%가 매일 사용하는 소수의 기능으로 처리된다는 것입니다. 이것은 실용적인 참조입니다 — 원시 타입, 유니온, 제네릭, 유틸리티 타입, 그리고 모든 것을 이해하게 만드는 좁히기 패턴. 추상적 이론 없이; 모든 예제는 실제 프로젝트에서 작성하는 종류의 코드입니다. JavaScript를 잘 알고 TypeScript를 어느 정도 해봤지만 정신적 모델을 확고히 하고 싶다면, 이것은 당신을 위한 것입니다. 전체 이야기는 TypeScript 핸드북에 있습니다 — 이 글은 80/20 버전입니다.

원시 타입과 리터럴

TypeScript의 원시 타입은 JavaScript의 런타임 타입에 직접 매핑됩니다: string, number, boolean, null, undefined, bigint, symbol. 처음 세 가지를 항상 사용하며; 나머지는 특정 컨텍스트에서 나타납니다.

ts
// Primitives — the foundation
let userId: number = 42;
let username: string = 'alice';
let isActive: boolean = true;
let deletedAt: Date | null = null;     // nullable pattern
let refreshToken: string | undefined;  // optional pattern

TypeScript가 흥미로워지는 것은 리터럴 타입입니다. 값이 string이라고만 말하는 대신, 특정 문자열 집합 중 하나여야 한다고 말할 수 있습니다. 이것은 TypeScript가 컴파일 시간에 유효하지 않은 값을 잡을 수 있기 때문에 일반 string 어노테이션보다 훨씬 유용합니다:

ts
type Direction = 'north' | 'south' | 'east' | 'west';
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
type StatusCode = 200 | 201 | 400 | 401 | 403 | 404 | 500;

function navigate(dir: Direction) {
  console.log(`Moving ${dir}`);
}

navigate('north');  // ✅
navigate('up');     // ❌ Argument of type '"up"' is not assignable to type 'Direction'

// Combine with string for "known values plus free-form"
type EventName = 'click' | 'focus' | 'blur' | (string & {});
끝의 (string & {}) 트릭은 알려진 값에 대한 자동 완성이 작동하도록 유지하면서 어떤 문자열이든 허용합니다. 디자인 시스템 라이브러리에서 일반적인 패턴입니다.

interface vs type — 언제 무엇을 사용할까

이것은 모든 TypeScript 초보자가 묻는 질문입니다. 실용적인 답변: 객체 형태 — 특히 공개 API 계약을 나타내거나 다른 타입이 확장할 것들 — 에는 interface를 사용하세요. 유니온, 교차, 매핑된 타입, 그리고 순수한 객체 형태가 아닌 모든 것에는 type을 사용하세요. 실제로 객체 형태에 대해서는 대부분 서로 바꾸어 사용할 수 있습니다 — 하나의 규칙을 선택하고 코드베이스 내에서 일관성을 유지하세요.

ts
// interface — object shapes, extensible via extends
interface User {
  id: number;
  email: string;
  createdAt: Date;
}

interface AdminUser extends User {
  role: 'admin';
  permissions: string[];
}

// type — unions, intersections, aliases for anything
type ID = string | number;
type Nullable<T> = T | null;

// Intersection — combine two shapes (common for mixins / HOC props)
type WithTimestamps = {
  createdAt: Date;
  updatedAt: Date;
};

type UserRecord = User & WithTimestamps;

// type is required here — interface can't express a union of shapes
type ApiResponse<T> =
  | { status: 'success'; data: T }
  | { status: 'error'; error: string; code: number };

한 가지 의미 있는 차이점: 인터페이스는 선언 병합을 지원합니다 — 동일한 인터페이스를 두 번 선언할 수 있으며 TypeScript가 정의를 병합합니다. 이것이 라이브러리가 전역 타입을 보강하는 방법입니다(예: Window에 속성 추가). 타입은 병합되지 않습니다; 타입을 다시 선언하는 것은 오류입니다.

유니온과 교차 타입

유니온 타입(A | B)은 "이 값은 A 또는 B"라고 말합니다. 교차 타입(A & B)은 "이 값은 동시에 A이자 B"라고 말합니다. 유니온은 실제 코드 어디에나 있습니다 — 그것들이 가능하게 하는 가장 강력한 패턴은 판별 유니온으로, 선택적 필드에 의존하지 않고 다른 상태에 있을 수 있는 데이터를 모델링하는 방법입니다.

ts
// Discriminated union — model API response states cleanly
type FetchState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string; retryable: boolean };

// TypeScript narrows the type inside each branch
function render<T>(state: FetchState<T>) {
  switch (state.status) {
    case 'idle':    return '<p>Not started</p>';
    case 'loading': return '<p>Loading...</p>';
    case 'success': return `<pre>${JSON.stringify(state.data)}</pre>`;
    case 'error':   return `<p>Error: ${state.error}</p>`;
  }
}

// Intersection — common in React/Angular for composing prop types
type ButtonBaseProps = {
  label: string;
  disabled?: boolean;
};

type IconButtonProps = ButtonBaseProps & {
  icon: string;
  iconPosition: 'left' | 'right';
};

판별 유니온 패턴은 판별자에 의존합니다 — 각 변형에 고유한 리터럴 속성(여기서는 status). TypeScript는 조건부 분기 내에서 전체 타입을 좁히기 위해 해당 속성을 사용하여, dataerror와 같은 변형별 필드에 타입 안전한 접근을 제공합니다.

제네릭 — 모든 사람을 혼란스럽게 하는 부분

동기 부여 문제: 여러 타입에서 작동하는 함수를 원하지만, any를 사용하면 모든 타입 정보가 날아갑니다. 제네릭이 이것을 해결합니다 — 각 괄호로 작성된 타입 매개변수로, 함수나 인터페이스가 완전한 타입 안전성을 유지하면서 타입의 패밀리에 걸쳐 작동하게 합니다.

ts
// Without generics — any loses all information
function first(arr: any[]): any {
  return arr[0];
}
const x = first([1, 2, 3]); // x is 'any' — TypeScript can't help you here

// With generics — T is inferred from the argument
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}
const n = first([1, 2, 3]);       // n: number | undefined ✅
const s = first(['a', 'b', 'c']); // s: string | undefined ✅

<T> 구문은 타입 매개변수를 선언합니다. 무엇이든 이름을 지정할 수 있습니다 — T는 단일 제네릭 타입에 대한 관례일 뿐입니다. 함수를 호출할 때 TypeScript는 인수에서 T를 추론하므로, 명시적으로 쓸 필요가 거의 없습니다. 제네릭 인터페이스는 API 형태 모델링에도 마찬가지로 유용합니다:

ts
// Generic interface — the API wrapper pattern every codebase has
interface ApiResponse<T> {
  data: T;
  meta: {
    page: number;
    totalPages: number;
    totalItems: number;
  };
}

interface User {
  id: number;
  name: string;
  email: string;
}

// The response type is fully typed — no casting needed
async function fetchUsers(): Promise<ApiResponse<User[]>> {
  const res = await fetch('/api/users');
  return res.json();
}

const response = await fetchUsers();
response.data[0].email; // ✅ TypeScript knows this is a string
response.meta.totalPages; // ✅ TypeScript knows this is a number

// Constrained generics — T must have an 'id' field
function findById<T extends { id: number }>(items: T[], id: number): T | undefined {
  return items.find(item => item.id === id);
}

const user = findById(users, 42); // T inferred as User
제네릭을 사용할 때: 서로 다른 타입으로 동일한 함수 서명을 두 번 작성하고 있거나, 원하는 반환 타입을 얻기 위해 as로 캐스팅하고 있을 때. 이 두 가지가 제네릭이 더 깔끔할 것이라는 신호입니다.

실제로 사용하게 될 유틸리티 타입

TypeScript는 기존 타입을 새로운 타입으로 변환하는 유틸리티 타입 집합을 내장하고 있습니다. 이것들은 타입 정의를 수동으로 복제하거나 수정할 필요를 없애줍니다. 다음은 실제 코드베이스에서 항상 나오는 것들입니다:

ts
interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user';
  createdAt: Date;
}

// Partial<T> — all properties become optional
// Perfect for PATCH/update payloads
type UpdateUserPayload = Partial<User>;
// { id?: number; name?: string; email?: string; ... }

async function updateUser(id: number, payload: Partial<User>) {
  return fetch(`/api/users/${id}`, {
    method: 'PATCH',
    body: JSON.stringify(payload)
  });
}

// Required<T> — all properties become required (opposite of Partial)
interface Config {
  apiUrl?: string;
  timeout?: number;
  retries?: number;
}
type ResolvedConfig = Required<Config>;
// { apiUrl: string; timeout: number; retries: number }

// Pick<T, K> — keep only specified properties
type UserSummary = Pick<User, 'id' | 'name' | 'email'>;
// Use in list views where you only need the display fields

// Omit<T, K> — exclude specified properties
type PublicUser = Omit<User, 'role' | 'createdAt'>;
// Safe to expose in API responses

// Readonly<T> — prevents mutation (great for config and frozen state)
const config: Readonly<ResolvedConfig> = {
  apiUrl: 'https://api.example.com',
  timeout: 5000,
  retries: 3
};
// config.timeout = 10000; // ❌ Cannot assign to 'timeout' because it is a read-only property

// Record<K, V> — typed dictionary
type UserCache = Record<number, User>;
const cache: UserCache = {};
cache[42] = { id: 42, name: 'Alice', email: '[email protected]', role: 'user', createdAt: new Date() };

// Common pattern: mapping string keys to a known value shape
type FeatureFlags = Record<string, { enabled: boolean; rolloutPct: number }>;

// ReturnType<T> — infer the return type of a function
function createSession(userId: number) {
  return {
    token: crypto.randomUUID(),
    userId,
    expiresAt: new Date(Date.now() + 86_400_000)
  };
}

type Session = ReturnType<typeof createSession>;
// { token: string; userId: number; expiresAt: Date }
// — no need to define the type separately and keep it in sync

unknown vs any vs never

이 세 가지 타입은 각각 어떤 문제를 해결하는지 이해하기 전까지 대부분의 개발자를 혼란스럽게 합니다. any는 탈출구입니다 — 해당 값에 대한 타입 검사를 완전히 비활성화합니다. JavaScript를 TypeScript로 마이그레이션할 때 유용하지만, 과도한 사용은 TypeScript의 목적을 무너뜨립니다. unknown은 타입 안전한 대안입니다: 값은 무엇이든 될 수 있지만, 무언가를 하기 전에 반드시 좁혀야 합니다. never는 절대 발생할 수 없는 타입 — 타입 계층의 바닥입니다.

ts
// any — type checking disabled, use sparingly
function dangerousTransform(input: any) {
  return input.toUpperCase(); // TypeScript won't warn even if this crashes at runtime
}

// unknown — safe alternative, forces you to check before using
function safeTransform(input: unknown): string {
  if (typeof input === 'string') {
    return input.toUpperCase(); // ✅ narrowed to string inside this block
  }
  if (typeof input === 'number') {
    return input.toFixed(2);    // ✅ narrowed to number
  }
  throw new Error(`Cannot transform value of type ${typeof input}`);
}

// Common use: parsing untrusted data (API responses, localStorage, user input)
function parseConfig(raw: unknown): ResolvedConfig {
  if (
    typeof raw === 'object' &&
    raw !== null &&
    'apiUrl' in raw &&
    typeof (raw as any).apiUrl === 'string'
  ) {
    return raw as ResolvedConfig;
  }
  throw new Error('Invalid config format');
}

// never — the exhaustive switch pattern
type Shape = { kind: 'circle'; radius: number } | { kind: 'square'; side: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle': return Math.PI * shape.radius ** 2;
    case 'square': return shape.side ** 2;
    default:
      // If you add a new Shape variant and forget to handle it here,
      // TypeScript will error: Type 'NewShape' is not assignable to type 'never'
      const _exhaustive: never = shape;
      throw new Error(`Unhandled shape: ${_exhaustive}`);
  }
}

실제에서의 타입 좁히기

타입 좁히기는 TypeScript가 광범위한 타입(string | number, unknown, 판별 유니온)을 조건부 블록 내에서 특정 타입으로 정제하는 방법입니다. TypeScript 좁히기 문서는 모든 가드를 깊이 다룹니다 — 다음은 매일 작성하게 될 패턴들입니다.

ts
// typeof — primitive narrowing
function formatValue(val: string | number | boolean): string {
  if (typeof val === 'string')  return val.trim();
  if (typeof val === 'number')  return val.toLocaleString();
  return val ? 'Yes' : 'No';
  // TypeScript knows val must be boolean here
}

// instanceof — class/object narrowing
function handleError(err: unknown): string {
  if (err instanceof Error)   return err.message;
  if (typeof err === 'string') return err;
  return 'An unknown error occurred';
}

// in operator — property existence check
type Cat = { meow(): void };
type Dog = { bark(): void };

function makeNoise(animal: Cat | Dog) {
  if ('meow' in animal) {
    animal.meow(); // TypeScript narrows to Cat
  } else {
    animal.bark(); // TypeScript narrows to Dog
  }
}

// Discriminated union narrowing (most common in component state)
type LoadState<T> =
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; message: string };

function renderState<T>(state: LoadState<T>) {
  if (state.status === 'success') {
    console.log(state.data);    // ✅ data is accessible
  } else if (state.status === 'error') {
    console.error(state.message); // ✅ message is accessible
  }
}

// Type predicates — custom type guards
function isUser(obj: unknown): obj is User {
  return (
    typeof obj === 'object' &&
    obj !== null &&
    'id' in obj &&
    'email' in obj
  );
}

// After isUser() returns true, TypeScript knows obj is User
function processPayload(payload: unknown) {
  if (isUser(payload)) {
    console.log(payload.email); // ✅ fully typed
  }
}

typeof 연산자는 가장 기본적인 좁히기 도구입니다. instanceof, in 연산자, 판별 속성 검사와 결합하면, any나 안전하지 않은 캐스트 없이 거의 모든 좁히기 시나리오를 처리할 수 있습니다.

마무리

TypeScript의 타입 시스템은 모호한 기능을 외우는 것보다 기본을 잘 배우는 것에서 보상을 줍니다. 리터럴 타입과 판별 유니온은 전체 런타임 버그 카테고리를 없애줍니다. 제네릭은 재사용 가능하고 타입 안전한 추상화를 작성하게 합니다. Partial, Pick, Omit, ReturnType과 같은 유틸리티 타입은 타입을 DRY하게 유지합니다. 그리고 좁히기를 통한 unknown은 외부 세계에서 데이터가 들어오는 가장자리에서도 타입의 안전성을 제공합니다. 이 모든 것을 확고히 하는 가장 좋은 방법은 대화식으로 실험하는 것입니다 — TypeScript 플레이그라운드는 어떤 스니펫이든 붙여넣고 즉시 추론된 타입을 볼 수 있게 해줍니다, 설정 없이. TypeScript 프로젝트에서 JSON 데이터를 다루고 있다면, 이 사이트의 JSON 포맷터JSON to TypeScript 도구가 실제 API 페이로드에서 인터페이스 정의를 자동으로 생성할 수 있습니다.