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

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

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