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
    };
}

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

TypeScript, 타입의 Key 이름을 변경하기

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

type Person = {
    name: string;
    age: number;
}

위의 타입을 다음처럼 변경하고자 합니다.

{
    my_name: string;
    my_age: number;
}

가장 간단한 방법은 다음과 같습니다.

type Prefix<T> = {
    [K in keyof T as `my_${string & K}`]: T[K]
}

type NPerson = Prefix<Person>;

이 글의 목적은 TypeScript 4.1에서 처음 제공되는 as 연산자입니다. as 연산자를 활용한 또 다른 예로써 특정 타입을 갖는 키를 갖는 타입을 정의하는 것입니다.

type OnlyStrings<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};

type NPerson2 = OnlyStrings<Person>;

위 코드 중 NPerson2는 다음과 같은 타입이 됩니다.

{
    name: string;
    // age: number; -> 제거
}

TypeScript를 이용한 TLP 예제 – 두 정수의 합

두 정수의 합을 타입 레벨(Type Level)에서 구할 수 있을까? 예를들어 다음과 같은 코드가 필요합니다.

type Result1 = Add<3, 4>; // 💡 타입 추론 결과: 7
type Result2 = Add<12, 8>; // 💡 타입 추론 결과: 20

Add라는 타입을 통해 두 정수의 합을 계산합니다. Add 타입에 대한 코드는 다음과 같습니다.

// N의 길이를 가지는 튜플 타입을 생성하는 헬퍼
type BuildTuple<Length extends number, Arr extends unknown[] = []> =
    Arr['length'] extends Length ? Arr : BuildTuple<Length, [...Arr, unknown]>;

// 두 숫자의 배열을 합쳐 전체 길이를 구하는 방식으로 덧셈 구현
type Add<A extends number, B extends number> =
    [...BuildTuple<A>, ...BuildTuple<B>]['length'];

위의 코드를 분석할 때 필요한 사전 지식은 다음과 같습니다. 튜플 타입의 길이는 타입 수준에서 미리 계산할 수 있다는 것이고 tupleType['length']처럼 얻을 수 있습니다.

TypeScript, 인자값에 대한 문자열 형식 강제하기

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

setPadding('10px'); // ✅
setPadding('2.5em'); // ✅
// setPadding('10');   // ❌ 에러: 'px', 'em', '%' 단위가 빠졌습니다.
// setPadding('tenpx'); // ❌ 에러: 숫자가 아닙니다.

setPadding이라는 함수는 길이에 대한 값을 받는데 숫자값과 단위가 조합된 하나의 문자열 값입니다. 이를 위해 setPadding 함수는 다음처럼 정의될 수 있습니다.

type CSSSize = `${number}px` | `${number}em` | `${number}%`;

function setPadding(padding: CSSSize) {
  console.log(padding);
  return { padding };
}