블로그 목록으로
개발

TypeScript 실전 베스트 프랙티스

typescriptjavascriptbest-practices

들어가며

TypeScript를 처음 도입했을 때는 "타입 붙이는 JavaScript"라고 가볍게 생각했다. 하지만 프로젝트 규모가 커지면서 TypeScript의 타입 시스템을 제대로 활용하는 것과 그렇지 않은 것 사이에는 엄청난 차이가 있다는 걸 체감했다. 이 글에서는 실제 프로젝트에서 검증한 TypeScript 베스트 프랙티스를 정리한다.

strict 모드는 무조건 켜라

tsconfig.json에서 strict: true를 설정하는 것이 첫 번째 원칙이다. strict 모드는 여러 엄격한 타입 체크 옵션을 한 번에 켜준다.

// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true
  }
}

noUncheckedIndexedAccess는 배열이나 객체의 인덱스 접근 결과에 undefined를 포함시켜준다. 런타임 에러를 컴파일 타임에 잡을 수 있는 강력한 옵션이다.

기존 프로젝트에 strict를 적용하기 어렵다면, 개별 옵션을 하나씩 켜가는 점진적 마이그레이션을 추천한다. strictNullChecks부터 시작하면 가장 효과가 크다.

any를 피하는 구체적인 방법

any를 쓰는 순간 TypeScript를 쓰는 의미가 절반으로 줄어든다. 하지만 현실에서는 "지금 당장 타입을 모르겠는데"라는 상황이 생긴다. 그때 any 대신 쓸 수 있는 대안들이 있다.

// 나쁜 예
function parseData(data: any): any {
  return data.value;
}

// 좋은 예: unknown 사용
function parseData(data: unknown): string {
  if (typeof data === 'object' && data !== null && 'value' in data) {
    return String((data as { value: unknown }).value);
  }
  throw new Error('Invalid data format');
}

// 좋은 예: 제네릭 사용
function parseData<T extends { value: string }>(data: T): string {
  return data.value;
}

unknownany와 달리 타입을 좁히기 전에는 어떤 연산도 할 수 없다. 덕분에 타입 가드를 강제하게 되고, 더 안전한 코드가 된다.

정말 어쩔 수 없이 any를 써야 한다면 ESLint에서 @typescript-eslint/no-explicit-any 규칙을 warn으로 설정해두자. 최소한 코드 리뷰에서 눈에 띄게 된다.

판별 유니온 (Discriminated Union)

복잡한 상태를 다룰 때 판별 유니온은 강력한 무기다. API 응답 처리가 대표적인 예시다.

type ApiResponse<T> =
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

function handleResponse(response: ApiResponse<User>) {
  switch (response.status) {
    case 'loading':
      return <Spinner />;
    case 'success':
      return <UserProfile user={response.data} />;
    case 'error':
      return <ErrorMessage message={response.error} />;
  }
}

status 필드가 판별자(discriminant) 역할을 해서, 각 분기에서 TypeScript가 자동으로 타입을 좁혀준다. success 분기에서는 data에 접근할 수 있고, error 분기에서는 error에 접근할 수 있다. 불가능한 상태를 타입 레벨에서 원천 차단하는 것이다.

타입 가드를 적극 활용하라

사용자 정의 타입 가드는 런타임 검증과 타입 추론을 동시에 해결해준다.

interface Admin {
  role: 'admin';
  permissions: string[];
}

interface Guest {
  role: 'guest';
  expiresAt: Date;
}

type User = Admin | Guest;

function isAdmin(user: User): user is Admin {
  return user.role === 'admin';
}

function getPermissions(user: User): string[] {
  if (isAdmin(user)) {
    return user.permissions; // Admin으로 추론됨
  }
  return []; // Guest로 추론됨
}

타입 가드 함수의 반환 타입 user is Admin이 핵심이다. 이 선언 덕분에 if 블록 안에서 TypeScript가 타입을 자동으로 좁혀준다.

제네릭을 두려워하지 마라

제네릭은 처음에는 복잡해 보이지만, 재사용 가능한 타입 안전 코드를 만드는 핵심 도구다. 핵심은 제약 조건(constraint)을 적절히 거는 것이다.

// 너무 넓은 제네릭
function getProperty<T>(obj: T, key: string): unknown {
  return (obj as Record<string, unknown>)[key];
}

// 제약이 있는 제네릭
function getProperty<T extends object, K extends keyof T>(
  obj: T,
  key: K
): T[K] {
  return obj[key];
}

const user = { name: '빛나라', age: 30 };
const name = getProperty(user, 'name'); // string으로 추론
// getProperty(user, 'email'); // 컴파일 에러!

K extends keyof T 제약 조건 덕분에 존재하지 않는 키로 접근하는 것을 컴파일 타임에 방지할 수 있다.

interface vs type: 언제 무엇을 쓸까

둘 다 비슷해 보이지만 명확한 차이가 있다.

// interface: 확장 가능, 선언 병합 지원
interface User {
  name: string;
}
interface User {
  age: number; // 선언 병합으로 합쳐짐
}

// type: 유니온, 인터섹션, 매핑된 타입 등 고급 기능
type Result = Success | Failure;
type ReadonlyUser = Readonly<User>;
type UserKeys = keyof User;

내 기준은 이렇다. 객체의 모양을 정의할 때는 interface, **유니온, 튜플, 조건부 타입 등 고급 타입 조합이 필요할 때는 type**을 쓴다. 라이브러리를 만들 때는 사용자가 확장할 수 있도록 interface를 쓰는 것이 좋다.

enum 대신 const 객체를 고려하라

TypeScript의 enum은 몇 가지 문제가 있다. 트리 셰이킹이 안 되고, 숫자 enum은 역방향 매핑 때문에 번들 크기가 커진다.

// enum의 문제
enum Direction {
  Up,    // 0
  Down,  // 1
  Left,  // 2
  Right, // 3
}
// 컴파일 후 양방향 매핑 객체가 생성됨

// const 객체 대안
const DIRECTION = {
  Up: 'up',
  Down: 'down',
  Left: 'left',
  Right: 'right',
} as const;

type Direction = typeof DIRECTION[keyof typeof DIRECTION];
// 'up' | 'down' | 'left' | 'right'

as const로 선언하면 문자열 리터럴 타입이 유지되고, 트리 셰이킹도 정상 작동한다. 다만 문자열 enum(enum Direction { Up = 'up' })은 역방향 매핑이 없어서 나쁘지 않은 선택이기도 하다. 팀 컨벤션에 따라 결정하면 된다.

유틸리티 타입 활용

TypeScript 내장 유틸리티 타입을 잘 활용하면 반복적인 타입 선언을 줄일 수 있다.

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

// 생성 시에는 id와 createdAt이 없다
type CreateUserInput = Omit<User, 'id' | 'createdAt'>;

// 수정 시에는 모든 필드가 선택적이다
type UpdateUserInput = Partial<Omit<User, 'id'>>;

// API 응답에서는 읽기 전용이다
type UserResponse = Readonly<User>;

// 특정 필드만 필요할 때
type UserSummary = Pick<User, 'id' | 'name'>;

Omit, Pick, Partial, Required, Readonly, Record 등을 조합하면 대부분의 파생 타입을 간결하게 표현할 수 있다.

타입 안전한 에러 처리

TypeScript에서 에러 처리는 의외로 약한 부분이다. catch 블록의 에러가 기본적으로 unknown이기 때문이다. Result 패턴을 활용하면 더 안전해진다.

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

async function fetchUser(id: string): Promise<Result<User>> {
  try {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) {
      return { ok: false, error: new Error(`HTTP ${response.status}`) };
    }
    const data = await response.json();
    return { ok: true, value: data as User };
  } catch (e) {
    return {
      ok: false,
      error: e instanceof Error ? e : new Error('Unknown error'),
    };
  }
}

// 사용하는 쪽
const result = await fetchUser('123');
if (result.ok) {
  console.log(result.value.name); // 안전하게 접근
} else {
  console.error(result.error.message); // 에러 처리
}

예외를 던지는 대신 Result를 반환하면, 호출하는 쪽에서 에러 처리를 강제할 수 있다. 에러가 무시되는 일이 줄어든다.

타입 안전한 API 호출

API 호출 시 요청과 응답 타입을 모두 관리하면 프론트엔드-백엔드 간 타입 불일치를 줄일 수 있다.

interface ApiEndpoints {
  'GET /users': { response: User[] };
  'GET /users/:id': { params: { id: string }; response: User };
  'POST /users': { body: CreateUserInput; response: User };
  'PUT /users/:id': { params: { id: string }; body: UpdateUserInput; response: User };
}

type ApiClient = {
  [K in keyof ApiEndpoints]: (
    args: Omit<ApiEndpoints[K], 'response'>
  ) => Promise<ApiEndpoints[K]['response']>;
};

이런 패턴을 사용하면 엔드포인트를 추가하거나 변경할 때 타입 에러가 자동으로 발생하므로, 누락된 수정 사항을 빠르게 찾을 수 있다.

마무리

TypeScript의 타입 시스템은 단순한 에러 방지 도구가 아니다. 코드의 의도를 명확히 표현하고, 리팩토링을 안전하게 하며, 팀 전체의 코드 이해도를 높이는 커뮤니케이션 도구다. 처음에는 타입 작성에 시간이 더 들지만, 프로젝트가 커질수록 그 투자가 몇 배로 돌아온다. strict 모드를 켜고, any를 피하고, 판별 유니온과 제네릭을 활용하는 것부터 시작해보자. 코드베이스가 눈에 띄게 견고해지는 것을 경험할 수 있다.

TypeScript 실전 베스트 프랙티스