TypeScript, 같은 타입이지만 다른 타입으로 만드는 방법(Branding)

Branding은 타입스크립트 고유 문법이 아닌 응용입니다. 브랜딩을 위해서는 먼저 다음과 같은 코드가 필요합니다.

type Brand<T, B extends string> =
    T & { readonly __brand: B };

위의 타입 정의를 통해 string 타입이지만 다른 용도로 정의할 수 있습니다. (브랜딩 화)

type UserId = Brand<string, "UserId">;
type PostId = Brand<string, "PostId">;

UserIdPostIdstring 타입이지만 이 둘은 서로 다른 타입입니다. 만약 다음과 같은 함수가 있다면..

function getPost(userId: UserId, postId: PostId) {
    // ...
}

위의 함수는 반드시 다음처럼 사용해야 합니다.

const userId = "hjkim" as UserId;
const postId = "3434" as PostId;

getPost(userId, postId); // ✅
// getPost(postId, userId); // ❌

TypeScript, Mapped Types

다음과 같은 타입이 있습니다.

type T = {
  id: string;
  postId: string;
}

위의 타입을 구성하는 키들은 "id" | "postId"인데, 이 키들로 구성된 타입은 다음 코드로 얻을 수 있습니다.

type KEYS = keyof T;

다음의 코드를 통해 T 타입을 구성하는 키들의 타입을 number로 변경할 수 있습니다.

type X = { [K in KEYS]: number };
// type X = { [K in "postId" | "useId"]: number };

즉, 위의 타입 결과는 다음과 같습니다.

type X = {
 id: number;
 postId: number;
}

Mapped Types를 이용해 각 키와 값에 대한 readonly 또는 optional로 변경할 수 있습니다.

type readonly_X = { +readonly [K in KEYS]: number };
type optional_X = { [K in KEYS]+?: number };

readonly? 앞에 + 또는 -를 지정함으로써 readonly와 optioanl을 지정할지(+) 제거할지(-)를 결정할 수 있습니다. 이 +-를 지정하지 않으면 +가 지정된 것으로 판단합니다.

TypeScript, 복잡한 중첩 객체 타입의 모든 중첩 키를 유니언 타입으로 추출

다음과 같은 복잡한 중첩 객체의 타입이 존재합니다.

// 테스트용 인터페이스
interface UserProfile {
  id: number;
  user: {
    name: string;
    address: {
      city: string;
      zipCode: number;
    };
  };
}

상기 중첩 객체 타입의 모든 키들을 유니언 타입으로 추출하면 그 결과는 다음과 같이 구성할 수 있습니다.

type UserPaths = DeepKeys<UserProfile>;
// 결과: "id" | "user" | "user.name" | "user.address" | "user.address.city" | "user.address.zipCode"

이런 결과를 만들어 주는 DeepKeys의 구현 코드는 다음과 같습니다.

type DeepKeys<T> = T extends object
  ? {
    [K in keyof T & (string | number)]: T[K] extends object
    ? `${K}` | `${K}.${DeepKeys<T[K]>}`
    : `${K}`;
  }[keyof T & (string | number)]
  : never;

TypeScript, 페이로드 타입에 안전한 이벤트 이미터(Event Emitter)

이벤트 종류마다 전달되는 데이터, 즉 페이로드(payload)의 타입은 모두 다릅니다. 예를들어 아래의 객체에서 key는 이벤트 이름, 값은 페이로드 형식입니다.

// 이벤트 맵 정의: 이벤트 이름 -> 페이로드 타입
type EventMap = {
  login: { userId: string; timestamp: number };
  logout: { userId: string };
  purchase: { itemId: string; price: number };
};

이제 위에서 정의된 이벤트를 등록하고 발생해주는(Emit) 다음과 같은 범용 클래스를 만들 수 있습니다.

class TypedEventEmitter<Events extends Record<string, any>> {
  ...
}

위의 TypedEventEmitter는 이벤트를 등록(on 매서드)할 수 있어야 하고 특정 상황에서 이벤트를 발생(emit 매서드)할 수 있어야 합니다. 그보다 먼저 등록될 이벤트를 저장하기 위한 컨테이너 필드가 필요합니다. 아래처럼요.

  private listeners: {
    [K in keyof Events]?: Array<(payload: Events[K]) => void>;
  } = {};

이벤트를 등록하는 on 매서드는 다음과 같습니다.

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

그리고 이벤트를 발생시키는 emit 매서드는 다음과 같구요.

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

이렇게 만든 클래스는 다음처럼 사용할 수 있습니다.

// 사용 예시
const emitter = new TypedEventEmitter<EventMap>();

emitter.on("login", (payload) => {
  // payload는 자동으로 { userId: string; timestamp: number } 타입으로 추론됨
  console.log(`${payload.userId}님이 로그인했습니다. (${payload.timestamp})`);
});

emitter.on("purchase", (payload) => {
  console.log(`아이템 ${payload.itemId} 구매, 가격: ${payload.price}원`);
});

emitter.emit("login", { userId: "user123", timestamp: Date.now() });
emitter.emit("purchase", { itemId: "item-42", price: 15000 });

// 아래처럼 잘못된 페이로드를 넘기면 컴파일 타임에 에러 발생!
// emitter.emit("login", { itemId: "wrong" }); // ❌ Type error

TypeScript, 처리 결과에 따라 반환 타입 정하기

어떤 함수에 대한 처리가 성공적일때 반환하는 타입과 실패했을때 반환하는 타입을 정하고 싶을 경우가 있습니다.

예를 들어서 다음과 같은 코드가 있습니다.

const result = divide(10, 0);
if (result.ok) {
  console.log(result.value); // number
} else {
  console.error(result.error.message);
}

divie는 받은 인자를 이용해 나누셈을 하는 함수로 10을 0으로 나누면 에러가 발생합니다. 실패에 대한 반환 타입과 성공에 대한 반환 타입을 구분하기 위해서 먼저 다음과 같은 타입을 정의합니다.

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

위의 코드를 이용해 앞서 봤던 divide 함수를 다음처럼 정의합니다.

function divide(a: number, b: number): Result<number> {
  if (b === 0)
    return {
      ok: false,
      error: new Error("0으로 나눌 수 없어!")
    };
  else
    return {
      ok: true,
      value: a / b
    };
}

내 삶은 어디로 수렴하는가? 그 목적지는 매일 내가 생각하고 행동하는 것들에 달려있다. 혹자는 이것이 운명이라 한다.