[Typescipt] 클래스를 타입으로서 사용할 때 주의점

Einere·2022년 11월 22일
0
post-thumbnail

1. 클래스 정의

간단하게 FooBar 라는 엔티티 클래스를 정의했다.

// Bar.ts
interface BarField {
  id: number;
}
export interface IBar {
  readonly _: BarField;

  get getId(): BarField["id"];
}

export class Bar implements IBar {
  constructor(public _: BarField) {}

  get getId() {
    return this._.id;
  }
}
  • BarField : 생성자 함수를 위한 타입
  • IBar : 클래스의 필드와 각종 메소드를 위한 타입
// Foo.ts
import { Bar, IBar } from "./Bar";

interface FooField {
  id: string;
  barList: Bar[]; // import 한 클래스 Bar 를 타입으로서 사용했다.
}

interface IFoo {
  readonly _: FooField;

  get getFirstBarId(): IBar["getId"] | undefined;
}

export class Foo implements IFoo {
  constructor(public _: FooField) {}

  get getFirstBarId() {
    const firstFoo = this._.barList[0];

    if (firstFoo) {
      return parseInt(firstFoo.getId);
    }
  }
}
  • FooField : 생성자 함수를 위한 타입
  • IFoo : 클래스의 필드와 각종 메소드를 위한 타입

Foothis.barList 라는 속성을 가지며, 타입은 Bar[] 이다. (Bar 클래스를 가져와서 타입으로 지정했다.)

TS 에러 발생

이 경우, getFirstBarId 에서 praseInt(...) 부분에서 타입 에러가 발생하는 것을 확인할 수 있다.

2. 타입 선언문을 타입 정의 파일로 옮기기

이제 각종 타입 선언문을 d.ts 파일로 옮겨보자.

💡 사실, 엄밀히 말하자면 `d.ts` 파일은 해당 프로젝트가 패키지(모듈 혹은 라이브러리)로 사용될 때, TS 환경에서 잘 작동할 수 있도록 타입들을 모아놓은 파일이다. 나는 단순히 타입을 사용할 때 `import` 구문을 쓰기 싫어서 `d.ts` 파일을 활용중이다.. 😅
// @types/Entity/index.d.ts
declare namespace Entity {
  namespace FooBar {
    import { Bar } from "../../src/Bar";

    interface FooField {
      id: string;
      barList: Bar[];
    }

    interface IFoo {
      readonly _: FooField;

      get getFirstBarId(): IBar["getId"] | undefined;
    }

    interface BarField {
      id: number;
    }
    export interface IBar {
      readonly _: BarField;

      get getId(): BarField["id"];
    }
  }
}

그리고 기존 FooBar 파일을 수정해주자.

// Bar.ts
export class Bar implements Entity.FooBar.IBar {
  constructor(public _: Entity.FooBar.BarField) {}

  get getId() {
    return this._.id;
  }
}
// Foo.ts
export class Foo implements Entity.FooBar.IFoo {
  constructor(public _: Entity.FooBar.FooField) {}

  get getFirstBarId() {
    const firstFoo = this._.barList[0];

    if (firstFoo) {
      return parseInt(firstFoo.getId);
    }
  }
}

그런데 신기하게도 에러가 발생했던 부분에서 에러가 사라졌다.

TS 에러가 사라짐

3. 해결하는 방법

정확한 이유는 파악하지 못했지만, 아무래도 d.ts 파일에서 non-primitive 타입은 추론이 제대로 동작하지 않게 되어버린 듯 하다. (다만 IDE에서 제공하는 타입 추론은 잘 작동한다.)

잘 작동하는 타입들은 primitive type 들이랑 interface , type 으로 선언한 타입 뿐인 듯 하다. (abstract class 같은 것도 선언은 가능하지만 추론이 제대로 동작하지 않는다.)

그래서 index.d.ts 에서 Bar[] 대신 IBar[] 를 사용하도록 바꾸었다.

declare namespace Entity {
  namespace FooBar {
    // import { Bar } from "../../src/Bar";

    interface FooField {
      id: string;
      // barList: Bar[];
      barList: IBar[];
    }

    interface IFoo {
      readonly _: FooField;

      get getFirstBarId(): IBar["getId"] | undefined;
    }

    interface BarField {
      id: number;
    }
    export interface IBar {
      readonly _: BarField;

      get getId(): BarField["id"];
    }
  }
}

그랬더니 다시 parseInt 부분에 타입 에러가 생겼다.

인터페이스를 통해 TS 타입 추론이 잘 작동하는 모습

💡 `barList` 는 `Bar` 인스턴스의 배열 타입인데, 단순한 인터페이스인 `IBar` 의 배열이여도 잘 동작하는 이유는, TS가 구조적 타이핑(덕 타이핑) 기반 언어이기 때문인 듯 하다. (`Bar` 클래스가 `IBar` 를 구현하기도 하고…)

4. 진정한 이유

스택오버플로우의 Import class in definition file (*d.ts) 글을 보니, 다음과 같은 내용이 써져 있었다.

타입 선언 모듈이 2가지 종류가 있다고 하네요.

  1. 흔히 ts 파일 내에 작성하는 지역 모듈
  2. d.ts 파일 내에 작성하는 전역 모듈

전역 모듈은 다른 이름으로 ambient module 이라고 하고, 이녀석은 지역 모듈에 이미 정의되어 있는 타입들에 병합될 수 있다고 합니다.

그리고 ambient module 은 inport 구문이 없어야 한다고 하네요. 그래서 제가 Bar 클래스를 import 한 시점에서 전역 모듈이 아니게 되어서 잘 작동을 안했던 것 같아요.

그래서 사실 import를 쓰지 않는 방법(제가 공유드렸던 아티클의 해결방법)이 유일한 해결책이었는데, 2.9버전 부터는 동적 import가 가능해졋다는 것 같아요 ㅎㅎ

결론은, 해당 전역 모듈이 지역 모듈화가 되어 버려, symbolic link 를 제대로 찾지 못해, 타입 추론이 제대로 이루어지지 않은 것이 원인인 듯 하다.

실제로 현업 코드에서 동적 import 를 이용해 클래스 자체를 가져와서 타입으로 사용해본 결과, 타입 추론이 잘 작동했다!

5. 해결 방법

위 스택 오버 플로우 링크에 나와있듯이, 동적 가져오기(dynamic import)를 사용하면 해결할 수 있습니다.

declare namespace Entity {
  namespace FooBar {
    interface FooField {
      id: string;
      // barList: IBar[];
      barList: import(".../Bar").Bar[]; // dynamic import
    }

    /* interface IFoo {
      readonly _: FooField;

      get getFirstBarId(): IBar["getId"] | undefined;
    } */

    interface BarField {
      id: number;
    }
    /* export interface IBar {
      readonly _: BarField;

      get getId(): BarField["id"];
    }/*
  }
}

불필요한 interface 를 제거하고, 클래스 자체를 동적 가져오기를 통해 타입으로서 사용해주면 정상적으로 작동한다.

profile
지속가능한 웹 개발자를 지향합니다. 경험의 공유를 통해 타인에게 도움이 되는 것을 좋아합니다. 사용자에게 가치를 제공하는 것에 기쁨을 느낍니다.

0개의 댓글