본문 바로가기

javascript/typescript

[typescript] generic + constructor 타입에서 생성자 파라미터 타입 추론

결론

생성자에 대한 타입을 지정할 때 반환 값 T만 지정하는 대신, 파라미터 P에 대한 정보도 함께 지정하면 ConstructorParameters을 통해 제네릭 타입의 생성자 파라미터를 추론할 수 있다.

IConstructor<T> = new (...args: any) => T 정의는 생성자 파라미터가 any로 고정되어 있어 추론이 발생하지 않으나, args의 타입도 추론하도록 제네릭 타입 매개변수 P로 분리해두면 ConstructorParameters가 이를 추론한다.

// 생성자 파라미터도 인식하는 클래스 생성자 타입 정의
export type IConstructor<T, P extends any[] = any[]> = new (...args: P) => T;

// 테스트 클래스

export class Bullet extends GameObject {
  private demage: number;
  private speed: number;

  constructor(
    position: Vec2D,
    direction: Vec2D,
    collider: Vec2D[],
    demage: number,
    speed: number,
  ) {
    super();
    this.addComponent(new Transform(position, direction));
    this.addComponent(new Collider(collider));
    this.demage = demage;
    this.speed = speed;
  }
}
// 생성자와 인자를 받아 내부 처리 후 객체를 생성해주는 함수
  createObject<T extends GameObject, P extends any[]>(
    ctor: IConstructor<T, P>,
    ...args: ConstructorParameters<typeof ctor>
  ) {
    const gameObject = new ctor(...args);
    this.manageObject(gameObject);
    return gameObject;
  }

생성자 파라미터를 추론할 수 있는 모습


 타입스크립트를 이용하여 객체지향적으로 게임을 구현하는 과제를 진행하고 있다. 이때, 게임 내 모든 객체를 ObjectManager을 거쳐 생성함으로써, 객체의 생명주기를 ObjectManager가 관리할 수 있도록 구조를 만들고 싶었다. ObjectManager의 코드는 다음과 같다.

export class ObjectManager {
  private gameObjects: GameObject[];

  private static manager: ObjectManager | null = null;

  static get instance(): ObjectManager {
    if (!this.manager) {
      this.manager = new ObjectManager();
    }
    return this.manager;
  }

  private constructor() {
    this.gameObjects = [];
  }

  createObject<T extends GameObject>(
    ctor: IConstructor<T>,
    ...args: ConstructorParameters<typeof ctor>
  ) {
    const gameObject = new ctor(...args);
    this.manageObject(gameObject);
    return gameObject;
  }

  manageObject<T extends GameObject>(gameObject: T) {
    this.gameObjects.push(gameObject);
    return gameObject;
  }

  /**
 * expired로 마킹된 객체들을 리스트에서 제거한다. 현재는 enemies, guns가 타겟이 된다.
 */
  deleteExpiredObjs() {
    for (let i = 0; i < this.gameObjects.length; i++) {
      if (!this.gameObjects[i].isExpired()) continue;

      //현 위치의 객체를 제거하고 인덱스를 1칸 내린다. (현재 인덱스에 다음 값이 오게 됨)
      this.gameObjects.splice(i, 1);
      i -= 1;
    }
  }

  getObjects(): readonly GameObject[] {
    return this.gameObjects;
  }
}

메서드의 역할을 요약하면 다음과 같다.

  • createObject: 생성자와 생성자 파라미터를 받아 객체를 생성해준다. 게임 내 모든 객체는 ObjectManager에서 생성되어야 하므로, 생성자 파라미터의 타입이 추론되기를 원한다.
  • manageObject: 생성한 게임 오브젝트를 관리 리스트에 넣는다.
  • deleteExpiredObjs: 만료된(제거되어야 할) 게임 오브젝트를 제거한다.
  • getObjects: 게임 오브젝트를 반환한다.

 게임 내 모든 객체는 createObject을 통해 생성되어 ObjectManager의 생명주기 관리 하에 포함되어야 한다. createObject는 생성자 및 생성자 파라미터 정보를 받으므로, 둘 모두 타입이 추론되기를 바란다.

 보통 클래스 생성자에 대한 타입은 다음과 같이 지정하는 경우가 많은 것 같다.

export type IConstructor<T> = new (...args: any) => T;

 반환될 클래스 타입도 명시할 수 있고, 인자가 존재한다는 사실도 ...args: any을 통해 알려줄 수 있으므로, 쓸만한 타입 정의로 보인다. 이제 이 인터페이스를 이용하여 createObject 메서드가 잘 동작하는지 보자.

//creatObject 정의

createObject<T extends GameObject>(
    ctor: IConstructor<T>,
    ...args: ConstructorParameters<typeof ctor>
) {
  const gameObject = new ctor(...args);
  this.manageObject(gameObject);
  return gameObject;
}

// 객체를 생성하는 코드
const objectManager = ObjectManager.instance;

const bullet = objectManager.createObject(Bullet)

생성자 파라미터가 전혀 추론되지 않는다.

생성자 파라미터 추론 기능이 전혀 동작하지 않는다. bullet을 생성하는데 필요한 위치, 방향 등의 요소를 전혀 추론하지 못하기 때문에, 함수는 정상적으로 실행되는 것처럼 보여도 필요한 값이 없는 비정상적인 클래스를 생성하게 된다.

처음에는 이게 왜 추론이 안될까 생각했지만, 당연한 일이다. 타입스크립트 컴파일러 입장에서는 애초에 추론할 필요가 없기 때문이다. 이에 대해 생각해보기 위해 IConstructor<T>의 타입 정의를 다시 살펴보자.

export type IConstructor<T> = new (...args: any) => T;

IConstructor<T>의 타입 정의에서는 생성자 파라미터를 any라고 정의되어 있다. 따라서 생성자 파라미터를 추출하는 유틸리티 타입인 ConstructorParameters<typeof ctor>은 ctor의 생성자 파라미터인 any를 그대로 가져온다. 위 정의에는 args가 추론 대상이라는 정보가 전혀 없으므로, 컴파일러는 그냥 any 타입을 그대로 가져다가 둔다.

이를 해결하는 방법은 ...args을 any로 두는 대신, 타입 추론이 발생할 수 있도록 제네릭 파라미터로 분리하는 것이다.

export type IConstructor<T, P extends any[] = any[]> = new (...args: P) => T;

createObject<T extends GameObject, P extends any[]>(
  ctor: IConstructor<T, P>,
  ...args: ConstructorParameters<typeof ctor>
) {
  const gameObject = new ctor(...args);
  this.manageObject(gameObject);
  return gameObject;
}

Bullet 클래스의 파라미터 타입이 추론된 모습

IConstructor<T, P> 정의에는 args의 타입인 P가 표현된다. 컴파일러는 생성되는 클래스의 타입인 T를 추론하듯이, 매개변수의 타입 P도 추론하게 되고, 추론된 타입 P 정보를 ConstructorParameters<typeof ctor>에서 잡아 표현하게 된다. 참고로 ConstructorParameters의 정의는 아래와 같다.

type ConstructorParameters<T extends abstract new (...args: any) => any>
= T extends abstract new (...args: infer P) => any ? P : never;

 

 IConstructor<T>의 경우 P를 infer을 통해 추론하더라도 any로 정해져 있다. 반면 IConstructor<T, P>의 경우 생성자 파라미터의 타입이 제네릭 타입 P로 지정되어 있으므로 추론이 발생, 추론한 타입을 반환하는 것으로 보인다.


제네릭 타입 매개변수를 이용하여 클래스 T를 추론하는 것은 당연하다고 생각했으면서, 왜 여태까지 파라미터를 P로 둘 생각은 하지 않았을까? 제네릭 추론이 무엇인지에 대해 한번 더 생각해보는 기회가 되었다.

'javascript > typescript' 카테고리의 다른 글

[typescript] ts2345  (0) 2023.11.24
[typescript] Type vs Interface  (1) 2023.10.11
[typescript] structural typing  (0) 2023.08.30
[nodejs] nodemon + ts-node을 es module 환경에서 사용하기  (0) 2023.05.19
[typescript] 오버로딩  (1) 2023.04.16