TypeScript - 외부 모듈 타입 선언하기 (feat. 첫 오픈소스 컨트리뷰션)

shkilo·2021년 4월 19일
1

문제 1.

NestJS 프로젝트에서 Sequelize 쿼리를 작성하는 중 문제가 발생했다.
service 에 다음과 같은 메소드가 있을 때,

async findAll(): Promise<User[]> {
    return await this.userModel.findAll({
      attributes: [
        'id',
        [sequelize.col('tasks.id'), 'defaultTaskId'], // aliasing
      ],
      include: [
        {
          model: Task,
          attributes: [],
        }
      ],
      order: ['tasks', 'createdAt', 'ASC'],
    });
  }

sequelize 에서는 위와 같이 include 된 테이블의 컬럼의 이름을 alias 할 수 있다.
하지만 타입스크립트와 사용했을 때 다음과 같은 문제가 발생했다.

attributes: [
  'id',
  [sequelize.col('tasks.id'), 'defaultTaskId'], // aliasing
  ^^^^^^^^^^^^^^^^^^^^^^^^^^  
 // Type 'Col' is not assignable to type 'string | Literal | Fn'.
],

문제 2. (이미 해결)

외부 모듈의 타입선언 관련 문제는 이전에도 한번 발생했다.
다음은 Controller의 한 메소드이다.

@Get()
async findAll(@Req() req: Request): Promise<Project[]> {
  return await this.projectService.findAll(req.user);
					   ^^^^^^^
 // Argument of type 'Express.User' is not assignable to parameter of type 'import("/some/path/src/user/user.model").User'
}

passport 인증을 거친 후 컨트롤러에서 req.user 를 사용하려 했다. 하지만 node_modules/@types/passport/index.d.ts 를 보면 다음과 같다.

declare global {
    namespace Express {
        interface AuthInfo {}
        interface User {}
      
        interface Request {
            authInfo?: AuthInfo;
            user?: User;
		    
          	// ... 생략
        }

        // ... 생략
    }
}

req.user 는 빈 인터페이스인걸 알 수 있다. 위 선언은 기존 express 의 타입선언을 declaration merging 을 통해 확장하는 과정이다.
node_modules/@types/express-serve-static-core 를 보면 다음 부분이 포함되어있다.

declare global {
    namespace Express {
        // These open interfaces may be extended in an application-specific manner via declaration merging.
        // See for example method-override.d.ts (https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/method-override/index.d.ts)
        interface Request {}
        interface Response {}
        interface Application {}
    }
}

따라서 나의 프로젝트에도 declaration 파일을 만들어 선언을 확장하면 된다.
src/types/index.d.ts 파일을 만들고,

import { User as UserModel } from '../user/user.model';

declare global {
  namespace Express {
    export interface User extends UserModel {}
  }
}

위와 같이 Express 네임스페이스의 User 타입이 내 앱에서 선언한 User 모델을 확장하도록 하면 문제가 해결된다.


다시 문제 1. 로 돌아가서

위에서 문제를 해결한것 처럼 src/types/index.d.ts 파일을 통해서 간단하게 해결할 수 있을줄 알았다. 하지만 Request 타입 문제는 application specific 하게 타입 확장을 할 수 있도록 일부러 글로벌에 namespace 를 통해 선언한 것이었고, 문제 1 은 그냥 sequelize 라이브러리의 타입 선언이 잘못된 것이었다.

잘못 선언된 부분을 찾아보니,
node_modules/sequelize/types/lib/model.d.ts 안의 다음 부분이었다.

export type ProjectionAlias = readonly [string | Literal | Fn, string];
                                                          ^^^^
                                  // string | Literal | Fn | Col 이어야 함
export type FindAttributeOptions =
  | (string | ProjectionAlias)[]
  | {
    exclude: string[];
    include?: (string | ProjectionAlias)[];
  }
  | {
    exclude?: string[];
    include: (string | ProjectionAlias)[];
  };

위와 같이 alias 문법이 튜플 형태로 표현되어 있는데 Col 이 빠져있었다.

이를 해결하기 위해서 sequlize 의 types 디렉토리의 하위 파일들을
src/types/sequelize 디렉토리로 복사하고,
src/types/sequelize/lib/model.d.ts 의 잘못된 부분을 고쳐준다.

이후 sequelize 모듈의 resolution path 를 추가한다.

{
  "compilerOptions": {
    // ... 생략
    "paths": {
      "sequelize": ["src/types/sequelize"]
    }
  }
}

위와 같이 tsconfig.json 의 컴파일러 옵션을 바꿔주면 해결된다.


TypeScript Module Resolution

'popo' 라는 외부 모듈이 있고 다음과 같이 import 한다고 치자.

// ./src/test/t.s
import Test from 'popo'

Non-relative resolution 과정은 다음과 같다.

  1. compilerOptions.paths 에 'popo' 에 대한 경로가 선언되어 있으면 그 경로를 탐색한다.

  2. 없다면 node_modules 폴더를 찾는다.
    src/test/node_modules 탐색, 없으면
    src/node_modules 탐색, 없으면
    /node_modules 탐색

    위와 같이 node_modules 를 찾을 때 까지 부모 디렉토리를 탐색하며 올라간다.
    (baseUrl 옵션이 선언되어있다면 선언된 위치까지)

  3. node_modules 폴더를 찾았다면 다음과 같은 순서로 탐색한다.

    /some/path/node_modules/popo.ts
    /some/path/node_modules/popo.tsx
    /some/path/node_modules/popo.d.ts
    /some/path/node_modules/popo/package.json (if it specifies a "types" property)
    /some/path/node_modules/@types/popo.d.ts
    /some/path/node_modules/popo/index.ts
    /some/path/node_modules/popo/index.tsx
    /some/path/node_modules/popo/index.d.ts
  1. 이래도 없으면 ambient module declaration 을 찾는다.

위와 같이 paths 에 지정된 경로를 최우선으로 찾는다. 그래서 모듈 선언을 대체하고 싶다면

"compilerOptions": {
  "paths": {
    "popo": ["your/new/declaration/path"]
  }
}

위와 같이 해결하면 된다.


Sequelize 레포에 반영하기

타입 선언을 Sequelize 에 반영하기 위해 이슈 템플릿에 맞춰 이슈 를 작성한다.

Sequelize 레포를 내 계정에 포크하고 브랜치를 생성한다. 코드 수정 후 커밋 컨벤션에 맞게 커밋 한다. Sequelize 는 앵귤러의 커밋 컨벤션을 따른다.

이후 PR 템플릿에 맞춰 PR 을 날린다.

한 일주일 후 머지 되고 버전 6.6.0 에 반영되었다.

이제 compilerOption.paths 를 생략하고 src/types/sequelize 디렉토리를 삭제해도 문제 없이 동작한다.

비록 티끌같은 커밋이지만 내가 자주 사용하는 라이브러리에 기여하는 과정이 꽤나 뿌듯하고 재미있었다.

참고

https://www.typescriptlang.org/tsconfig
https://www.typescriptlang.org/docs/handbook
https://medium.com/naver-fe-platform/타입스크립트-컴파일러가-모듈-타입-선언을-참조하는-과정-5bfc55a88bb6

0개의 댓글