TypeScript, this 라는 타입에 관하여

타입스크립트에서는 클래스에서 사용되는 this라는 타입이 있습니다. 이 this 타입은 동적으로 현재 클래스에 대한 타입으로 결정됩니다. 애매하고 어렵죠? 예시를 통해 좀더 살펴보면..

class Box {
  content: string = "";
  sameAs(other: this) {
    return other.content === this.content;
  }
}
 
class DerivedBox1 extends Box {
  otherContent: string = "?";
}

class DerivedBox2 extends Box {
  otherContent: string = "?";
}
 
const base = new Box();
const derived1 = new DerivedBox1();
derived1.sameAs(base);

const derived2 = new DerivedBox2();
derived2.sameAs(base);

위의 코드에서 Box 클래스의 sameAs 인자의 other 타입이 this입니다. 이 Box 클래스를 상속받는 파생클래스들을 통해 sameAs를 사용할 경우 sameAs의 첫번째 인자인 other는 각 파생클래스의 타입이 됩니다. 즉 derived1sameAs 매서드의 정의는 다음과 같고…

sameAs(other: DerivedBox1): boolean

derived2sameAs 매서드의 정의는 다음과 같습니다.

sameAs(other: DerivedBox2): boolean

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); // ❌

참고로 Brand의 두번째 제네릭 타입 인자로 전달되는 문자열 타입이 저장되는 __brand는 타입 정보일뿐이므로 Javascript 단에서는 존재하지 않습니다.

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