이펙티브 타입스크립트 4장: 요약 및 핵심 정리

2025. 1. 25. 12:48Programming Language/Typescript

반응형

타입스크립트는 자바스크립트의 강력한 확장 언어로, 타입 시스템을 통해 코드의 안정성과 가독성을 높일 수 있습니다. 하지만 올바르게 사용하지 않으면 타입스크립트의 강점이 약점으로 작용할 수 있습니다. 이번 글에서는 "이펙티브 타입스크립트" 4장의 내용을 기반으로 타입 설계의 원칙과 모범 사례를 정리합니다.


1. 유효한 상태만 표현하는 타입을 지향하기

타입을 설계할 때, 무효한 상태를 표현할 수 있는 타입은 잠재적으로 오류를 유발합니다. 이를 방지하기 위해 유효한 상태만을 표현하는 타입을 사용하는 것이 중요합니다.

잘못된 예시

interface State {
  pageText: string;
  isLoading: boolean;
  error?: string;
}

위 타입에서는 isLoadingtrue이면서 error가 존재하거나, isLoadingfalse인데 pageText가 비어있는 상태를 표현할 수 있습니다.

개선된 예시: 태그된 유니온 사용

interface RequestPending {
  state: 'pending';
}

interface RequestError {
  state: 'error';
  error: string;
}

interface RequestSuccess {
  state: 'success';
  pageText: string;
}

type RequestState = RequestPending | RequestError | RequestSuccess;

interface State {
  currentPage: string;
  requests: { [page: string]: RequestState };
}

태그된 유니온을 사용하면 각 상태를 명확하게 구분할 수 있어 코드의 안정성과 가독성이 향상됩니다.

API 응답 상태나 폼 유효성 검사를 설계할 때 유효한 상태만 표현하는 타입을 사용하세요.


2. 사용할 때는 너그럽게, 생성할 때는 엄격하게

함수를 설계할 때는 매개변수는 유연하게 받고, 반환값은 엄격하게 정의하는 것이 좋습니다. 이렇게 하면 함수의 재사용성이 높아지고 오류를 방지할 수 있습니다.

예시: 지도 애플리케이션

interface LngLat {
  lng: number;
  lat: number;
}

type LngLatLike = LngLat | { lon: number; lat: number } | [number, number];

interface CameraOptions {
  center?: LngLatLike;
  zoom?: number;
  bearing?: number;
  pitch?: number;
}

interface Camera {
  center: LngLat;
  zoom: number;
  bearing: number;
  pitch: number;
}

function setCamera(camera: CameraOptions): void {
  // 구현부
}

매개변수 center는 다양한 형태를 허용하여 유연성을 높였지만, 반환값 타입은 엄격하게 정의하여 오류를 방지합니다.

외부에서 받는 데이터를 처리하거나 복잡한 입력 형태를 허용해야 하는 함수에서 활용하세요.


3. 문서에 타입 정보를 쓰지 않기

타입스크립트의 타입 시스템을 적극 활용하여 주석에 타입 정보를 작성하지 않는 것이 좋습니다. 주석에 타입 정보를 쓰면 코드와 주석 간의 불일치가 발생할 가능성이 높아집니다.

잘못된 예시

/** nums를 변경하지 않습니다. */
function sort(nums: number[]) {
  // 구현부
}

개선된 예시

function sort(nums: readonly number[]): number[] {
  // 구현부
}

readonly 키워드를 사용하여 불변성을 명시적으로 표현하면 주석이 불필요해집니다.

타입스크립트의 기능을 적극 활용하여 불필요한 주석을 줄이고, 타입 정보는 타입 시스템에 맡기세요.


4. 타입 주변에 null 값 배치하기

타입 설계 시 값이 전부 null이거나 전부 null이 아닌 상태로 분명히 구분되도록 설계해야 합니다. 이렇게 하면 null 체크를 간소화하고 코드의 안정성을 높일 수 있습니다.

예시

function extent(nums: number[]): [number, number] | null {
  let result: [number, number] | null = null;
  for (const num of nums) {
    if (!result) {
      result = [num, num];
    } else {
      result = [Math.min(num, result[0]), Math.max(num, result[1])];
    }
  }
  return result;
}

반환값이 [number, number]이거나 null로 명확히 구분되므로 이후 로직에서 쉽게 처리할 수 있습니다.

데이터의 유효성과 초기화 상태를 명확히 구분해야 하는 경우 유용합니다.


5. 유니온의 인터페이스보다는 인터페이스의 유니온을 사용하기

타입 설계 시, 유니온 타입의 인터페이스보다는 인터페이스의 유니온 타입을 사용하는 것이 더 명확하고 안전합니다.

잘못된 예시

interface Layer {
  type: 'fill' | 'line' | 'point';
  layout: FillLayout | LineLayout | PointLayout;
  paint: FillPaint | LinePaint | PointPaint;
}

type과 layout, paint 간의 관계가 명확하지 않아 오류를 유발할 수 있습니다.

개선된 예시

interface FillLayer {
  type: 'fill';
  layout: FillLayout;
  paint: FillPaint;
}

interface LineLayer {
  type: 'line';
  layout: LineLayout;
  paint: LinePaint;
}

interface PointLayer {
  type: 'point';
  layout: PointLayout;
  paint: PointPaint;
}

type Layer = FillLayer | LineLayer | PointLayer;

각 타입별 속성을 명확히 분리하여 타입 안전성을 높였습니다.

여러 속성이 서로 연관된 경우 인터페이스의 유니온을 사용하세요.


6. 문자열 타입 남발 선언을 피하기

타입을 선언할 때, 모든 문자열을 허용하는 string 타입보다는 더 구체적인 타입을 선언하는 것이 좋습니다. 이는 타입 안정성을 높이고 런타임 오류를 방지합니다.

잘못된 예시

function getPermissions(role: string) {
  if (role === 'admin') {
    return 'Full Access';
  } else if (role === 'user') {
    return 'Limited Access';
  } else if (role === 'guest') {
    return 'Guest Access';
  } else {
    return 'Unknown Role';
  }
}

개선된 예시: 문자열 리터럴 타입의 유니온 활용

type Role = 'admin' | 'user' | 'guest';

function getPermissions(role: Role): string {
  const permissions: { [key in Role]: string } = {
    admin: 'Full Access',
    user: 'Limited Access',
    guest: 'Guest Access',
  };

  return permissions[role];
}

개선된 예시: Enum 타입 활용

enum Role {
  Admin = 'admin',
  User = 'user',
  Guest = 'guest',
}

function getPermissions(role: Role) {
  switch (role) {
    case Role.Admin:
      return 'Full Access';
    case Role.User:
      return 'Limited Access';
    case Role.Guest:
      return 'Guest Access';
  }
}

허용된 값이 제한적일 때 문자열 리터럴 타입이나 Enum을 사용하세요.


7. 잘못된 타입 선언보다 타입이 없는 것이 낫다

타입을 선언할 때 잘못된 타입을 정의하면 타입스크립트의 검증을 무력화하거나 오히려 오류를 유발할 수 있습니다. 이 경우 차라리 타입 선언을 생략하고, 올바른 타입을 나중에 정의하는 것이 더 나은 선택일 수 있습니다.

잘못된 예시

interface User {
  id: string; // 실제로는 number
  name: string;
}

const user: User = { id: 123, name: 'Alice' }; // 컴파일 오류 없음

위 예시는 id의 타입을 잘못 정의하여 런타임 오류를 유발할 수 있습니다.

개선된 예시

const user = { id: 123, name: 'Alice' }; // 타입 추론 사용

// 또는
interface User {
  id: number;
  name: string;
}

const user: User = { id: 123, name: 'Alice' }; // 올바른 타입 적용

타입스크립트는 타입 추론을 통해 기본적인 타입 검증을 제공하므로, 잘못된 타입 선언을 방지하기 위해 타입 생략이 더 나을 수 있습니다.

정확한 타입을 선언할 수 없는 상황에서는 타입 추론에 의존하고, 나중에 명확한 타입을 정의하세요.


8. 데이터보다는 명세로부터 코드를 생성하기

코드에서 데이터를 하드코딩하거나 명세와 분리하여 관리하면 유지보수와 확장성이 떨어질 수 있습니다. 대신 명세(스키마, API 정의 등)로부터 코드를 생성하는 접근이 바람직합니다. 이는 코드와 데이터 간의 일관성을 유지하고, 변경 사항 반영을 용이하게 합니다.

잘못된 예시

const roles = [
  { id: 1, name: 'Admin' },
  { id: 2, name: 'User' },
  { id: 3, name: 'Guest' },
];

function getRoleName(id: number): string | undefined {
  return roles.find(role => role.id === id)?.name;
}

위 코드에서는 데이터가 하드코딩되어 있어 역할이 추가되거나 변경될 경우 코드 수정이 필요합니다.

개선된 예시

// 명세 (예: API 스키마)
const roleSchema = {
  Admin: 1,
  User: 2,
  Guest: 3,
} as const;

type Role = keyof typeof roleSchema;

function getRoleName(role: Role): string {
  return role;
}

위 예시에서는 명세를 기반으로 타입과 로직을 생성하여 코드와 데이터의 일관성을 유지합니다.

OpenAPI 스키마나 GraphQL 스키마를 활용해 타입과 코드를 자동 생성하세요. 이를 통해 데이터 변경 시 수정 범위를 최소화하고 일관성을 유지할 수 있습니다.


9. 해당 분야의 용어로 타입 이름을 짓기

타입 이름을 정할 때, 일반적인 이름보다 해당 분야에서 통용되는 용어를 사용하는 것이 좋습니다. 이는 코드의 가독성을 높이고, 도메인 지식을 반영하여 협업 시 의사소통을 원활하게 합니다.

잘못된 예시

interface Data {
  id: number;
  value: string;
}

위 예시에서는 타입의 목적을 명확히 알기 어렵습니다.

개선된 예시

interface Product {
  productId: number;
  productName: string;
}

도메인과 관련된 용어를 사용하여 타입의 의도를 분명히 전달합니다.

비즈니스 로직에 사용되는 명세와 용어집을 참고하여 타입 이름을 지으세요. 이렇게 하면 코드가 명확하고 유지보수하기 쉬워집니다.


10. Typescript의 브랜딩 기능 사용하기

타입스크립트는 브랜딩(branding) 기법을 사용하여 더 구체적이고 안전한 타입을 정의할 수 있습니다. 브랜딩은 특정 타입에 추가적인 속성을 부여하여 해당 타입만을 명확히 구분하고 의도를 전달하는 데 유용합니다.

예시: 기본 타입에 브랜딩 추가하기

type UserId = number & { readonly brand: unique symbol };

declare function createUserId(id: number): UserId;

function getUserById(id: UserId) {
  // UserId 타입만 허용
}

const id = createUserId(123);
getUserById(id); // 정상
getUserById(123); // 오류: 일반 number는 허용되지 않음

브랜딩을 통해 UserId는 단순히 number가 아닌, 특정한 목적으로 사용되는 타입임을 명확히 할 수 있습니다.

예시: 반환 값에 브랜딩 타입 적용하기

type OrderId = string & { readonly brand: unique symbol };

declare function generateOrderId(): OrderId;

const orderId = generateOrderId(); // 반환값은 OrderId 타입

function processOrder(id: OrderId) {
  // OrderId 타입만 허용
}

processOrder(orderId); // 정상
processOrder("12345"); // 오류: 일반 string은 허용되지 않음

브랜딩된 타입을 반환 값에 적용하면, 함수의 출력 타입을 명확히 구분하고 잘못된 사용을 방지할 수 있습니다.

API 키, 사용자 ID 등 서로 다른 목적의 기본 타입을 명확히 구분해야 하는 상황에서 브랜딩을 활용하세요.


결론

"이펙티브 타입스크립트" 4장에서 다룬 내용은 타입스크립트를 효과적으로 사용하기 위한 기본 원칙과 모범 사례를 제시합니다. 유효한 상태를 표현하는 타입 설계, 함수 매개변수와 반환값의 유연성과 엄격성, 타입스크립트 타입 시스템의 적극적 활용 등을 실무에 적용하면 코드의 안정성과 가독성을 크게 향상시킬 수 있습니다.

여러분의 타입스크립트 코드에서도 이러한 원칙을 적용해보세요! :)

반응형